Skip to content

Commit db54f84

Browse files
author
Jon Waldstein
committed
chore: merge develop
2 parents 424ca1d + 72a11b3 commit db54f84

File tree

36 files changed

+413
-214
lines changed

36 files changed

+413
-214
lines changed

src/API/REST/V3/Routes/Campaigns/GetCampaignRevenue.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ public function mapResultsByDate(array $results, string $groupBy): array
210210
}
211211

212212
/**
213-
* @since unreleased
213+
* @since 4.10.0
214214
*/
215215
public function getSchema(): array
216216
{

src/API/REST/V3/Routes/Donors/DonorController.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,26 @@ public function get_item_schema(): array
449449
],
450450
],
451451
],
452+
'customFields' => [
453+
'type' => 'array',
454+
'readonly' => true,
455+
'description' => esc_html__('Custom fields (sensitive data)', 'give'),
456+
'items' => [
457+
'type' => 'object',
458+
'properties' => [
459+
'label' => [
460+
'type' => 'string',
461+
'description' => esc_html__('Field label', 'give'),
462+
'format' => 'text-field',
463+
],
464+
'value' => [
465+
'type' => 'string',
466+
'description' => esc_html__('Field value', 'give'),
467+
'format' => 'text-field',
468+
],
469+
],
470+
],
471+
],
452472
],
453473
];
454474

src/Admin/ajv.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {__, sprintf} from '@wordpress/i18n';
22
import {JSONSchemaType} from 'ajv';
3-
import addFormats from 'ajv-formats';
43
import addErrors from 'ajv-errors';
4+
import addFormats from 'ajv-formats';
55

66
/**
77
* Create an AJV resolver for react-hook-form with WordPress REST API schema
@@ -15,6 +15,7 @@ import addErrors from 'ajv-errors';
1515
* Key advantage: WordPress REST API supports most JSON Schema Draft 4 features but lacks
1616
* some advanced features (if/then/else, allOf, not) that AJV can provide for enhanced frontend validation.
1717
*
18+
* @since 4.10.0 Refactor transformWordPressSchemaToDraft7 to handle readonly/readOnly fields and conditionally remove enum from nullable fields when value is null to prevent AJV conflicts
1819
* @since 4.9.0
1920
*
2021
* @param schema - The JSON Schema from WordPress REST API
@@ -30,7 +31,7 @@ export function ajvResolver(schema: JSONSchemaType<any>) {
3031

3132
const transformedData = transformFormDataForValidation(data, schema);
3233
const ajv = configureAjvForWordPress();
33-
const transformedSchema = transformWordPressSchemaToDraft7(schema);
34+
const transformedSchema = transformWordPressSchemaToDraft7(schema, data);
3435
const validate = ajv.compile(transformedSchema);
3536
const valid = validate(transformedData);
3637

@@ -64,7 +65,6 @@ export function ajvResolver(schema: JSONSchemaType<any>) {
6465
};
6566
}
6667

67-
6868
/**
6969
* Configure standard AJV (Draft 7/2019-09) for WordPress REST API compatibility
7070
*
@@ -143,6 +143,8 @@ function configureAjvForWordPress() {
143143
// Add WordPress-specific custom formats that are not in the standard
144144
ajv.addFormat('text-field', true); // WordPress custom format - no validation, only sanitization
145145
ajv.addFormat('textarea-field', true); // WordPress custom format - no validation, only sanitization
146+
ajv.addFormat('integer', true); // WordPress custom format - no validation, only sanitization
147+
ajv.addFormat('boolean', true); // WordPress custom format - no validation, only sanitization
146148

147149
// Transform WordPress schemas to be compatible with Draft 7/2019-09
148150
// This converts Draft 03/04 syntax (required: true on properties) to Draft 7 syntax
@@ -167,13 +169,15 @@ function configureAjvForWordPress() {
167169
* with AJV (Draft 7/2019-09). The transformation includes:
168170
* - Converts 'required: true' on individual properties to 'required' array at object level
169171
* - Updates $schema reference from Draft 04 to Draft 7/2019-09
172+
* - Removes readonly/readOnly fields from validation (they shouldn't be validated by frontend)
173+
* - Conditionally removes enum from nullable fields when value is null to prevent AJV conflicts
170174
* - Preserves all advanced features (if/then/else, allOf, etc.) that WordPress ignores
171175
* but AJV can use for enhanced frontend validation
172176
*
173177
* Key benefit: WordPress schemas can include advanced validation rules (if/then/else, allOf, not)
174178
* that are ignored by the backend but utilized by the frontend for better UX.
175179
*/
176-
function transformWordPressSchemaToDraft7(schema: JSONSchemaType<any>): JSONSchemaType<any> {
180+
function transformWordPressSchemaToDraft7(schema: JSONSchemaType<any>, data?: any): JSONSchemaType<any> {
177181
if (!schema || typeof schema !== 'object') {
178182
return schema;
179183
}
@@ -191,15 +195,36 @@ function transformWordPressSchemaToDraft7(schema: JSONSchemaType<any>): JSONSche
191195

192196
Object.keys(transformed.properties).forEach((key) => {
193197
const prop = transformed.properties[key];
194-
if (prop && typeof prop === 'object' && prop.required === true) {
198+
199+
// Early return if prop is not a valid object
200+
if (!prop || typeof prop !== 'object') {
201+
return;
202+
}
203+
204+
// Converts 'required: true' on individual properties to 'required' array at object level
205+
if (prop.required === true) {
195206
requiredFields.push(key);
196207
delete prop.required;
197208
}
198209

199-
// Add custom error messages for each property
200-
if (prop && typeof prop === 'object') {
201-
errorMessages[key] = getCustomErrorMessage(prop, key);
210+
// Remove readonly/readOnly fields from validation (they shouldn't be validated by frontend)
211+
if (prop.readonly === true || prop.readOnly === true) {
212+
delete transformed.properties[key];
213+
return;
202214
}
215+
216+
// For WordPress Array type + enum (like honorific), conditionally remove enum based on current value
217+
// This prevents AJV conflicts when nullable fields have null values
218+
if (Array.isArray(prop.type) && prop.enum) {
219+
const currentValue = data && data[key];
220+
const allowsNull = prop.type.includes('null');
221+
if (currentValue === null && allowsNull) {
222+
delete prop.enum;
223+
}
224+
}
225+
226+
// Add custom error messages for each property
227+
errorMessages[key] = getCustomErrorMessage(prop, key);
203228
});
204229

205230
if (requiredFields.length > 0) {
@@ -293,7 +318,6 @@ function getCustomErrorMessage(prop: any, fieldName: string): string {
293318
return sprintf(__('%s is invalid.', 'give'), fieldName);
294319
}
295320

296-
297321
/**
298322
* Transform form data to be compatible with JSON Schema validation
299323
*
@@ -515,4 +539,3 @@ function transformOneOfValue(value: any, oneOfSchemas: any[]): any {
515539
// Fallback: return null
516540
return null;
517541
}
518-
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* @since 4.0.0
3+
*/
4+
export function amountFormatter(
5+
currency: Intl.NumberFormatOptions['currency'],
6+
options?: Intl.NumberFormatOptions
7+
): Intl.NumberFormat {
8+
return new Intl.NumberFormat(navigator.language, {
9+
style: 'currency',
10+
currency: currency,
11+
...options,
12+
});
13+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* @since 4.10.0
3+
*/
4+
export function formatTimestamp(timestamp: string | null | undefined, useComma: boolean = false): string {
5+
// Handle null, undefined, or empty string
6+
if (!timestamp) {
7+
return '—';
8+
}
9+
10+
const date = new Date(timestamp);
11+
12+
// Check if the date is valid
13+
if (isNaN(date.getTime())) {
14+
return '—';
15+
}
16+
17+
const day = date.getDate();
18+
const ordinal = (day: number): string => {
19+
if (day > 3 && day < 21) return 'th';
20+
switch (day % 10) {
21+
case 1:
22+
return 'st';
23+
case 2:
24+
return 'nd';
25+
case 3:
26+
return 'rd';
27+
default:
28+
return 'th';
29+
}
30+
};
31+
32+
const dayWithOrdinal = `${day}${ordinal(day)}`;
33+
const month = date.toLocaleString('en-US', {month: 'long'});
34+
const year = date.getFullYear();
35+
const time = date.toLocaleString('en-US', {hour: 'numeric', minute: '2-digit', hour12: true}).toLowerCase();
36+
const separator = useComma ? ', ' : ' • ';
37+
38+
return `${dayWithOrdinal} ${month} ${year}${separator}${time}`;
39+
}
40+
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {formatDistanceToNow} from 'date-fns';
2+
3+
/**
4+
* Returns a relative time string for a given date (e.g. "Today" or "2 days ago")
5+
*
6+
* @since 4.10.0
7+
*/
8+
export function getRelativeTimeString(date: Date): string {
9+
const now = new Date();
10+
if (date.toDateString() === now.toDateString()) {
11+
return 'Today';
12+
}
13+
return formatDistanceToNow(date, {addSuffix: true});
14+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { SchemaProperty } from '../types';
2+
3+
/**
4+
* @since 4.10.0
5+
*/
6+
export function prepareDefaultValuesFromSchema(
7+
record: Record<string, any>,
8+
schemaProperties: Record<string, SchemaProperty>
9+
): Record<string, any> {
10+
const isReadOnly = (schema: SchemaProperty): boolean => {
11+
return schema?.readOnly || schema?.readonly;
12+
};
13+
14+
const isObject = (value: any, schema: SchemaProperty): boolean => {
15+
return schema?.properties &&typeof value === "object" && value !== null && !Array.isArray(value);
16+
};
17+
18+
const isArray = (value: any, schema: SchemaProperty): boolean => {
19+
return schema?.type === "array" && schema?.items && Array.isArray(value);
20+
};
21+
22+
const processValue = (value: any, schema: SchemaProperty): any => {
23+
if (isObject(value, schema)) {
24+
return prepareDefaultValuesFromSchema(value, schema.properties as Record<string, SchemaProperty>);
25+
}
26+
27+
if (isArray(value, schema)) {
28+
return value.map(item =>
29+
isObject(item, schema.items as SchemaProperty)
30+
? prepareDefaultValuesFromSchema(item, (schema.items as any).properties ?? {})
31+
: item
32+
);
33+
}
34+
35+
return value;
36+
};
37+
38+
return Object.fromEntries(
39+
Object.entries(record)
40+
.filter(([key]) => !isReadOnly(schemaProperties[key]))
41+
.map(([key, value]) => [
42+
key,
43+
schemaProperties[key]
44+
? processValue(value, schemaProperties[key])
45+
: value
46+
])
47+
);
48+
}

src/Admin/components/Notices/index.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,21 @@ import {__} from '@wordpress/i18n';
44
import styles from './styles.module.scss';
55

66
/**
7+
* @since 4.10.0 Add className prop
78
* @since 4.8.0
89
*/
910
interface Props {
1011
type: 'info' | 'warning' | 'error';
12+
className?: string;
1113
children: React.ReactNode;
1214
dismissHandleClick?: () => void;
1315
}
1416

1517
/**
18+
* @since 4.10.0 Add className prop
1619
* @since 4.8.0
1720
*/
18-
export default ({type, children, dismissHandleClick}: Props) => {
21+
export default ({type, children, dismissHandleClick, className}: Props) => {
1922
const [isVisible, setIsVisible] = useState(true);
2023

2124
const handleDismiss = () => {
@@ -29,7 +32,7 @@ export default ({type, children, dismissHandleClick}: Props) => {
2932
return null;
3033
}
3134

32-
const noticeClasses = `${styles.notice} ${
35+
const noticeClasses = `${styles.notice} ${className} ${
3336
type === 'warning' ? styles.warning : type === 'error' ? styles.error : styles.info
3437
}`;
3538

src/Admin/fields/Status/index.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { __ } from "@wordpress/i18n";
2+
import cx from "classnames";
3+
import { AdminSectionField } from "@givewp/components/AdminDetailsPage/AdminSection";
4+
import Notice from "@givewp/admin/components/Notices";
5+
import { useFormContext, useFormState } from "react-hook-form";
6+
import styles from "./styles.module.scss";
7+
8+
/**
9+
* @since 4.10.0
10+
*/
11+
export default function Status({statusOptions}: {statusOptions: Record<string, string>}) {
12+
const {register, watch} = useFormContext();
13+
const {errors} = useFormState();
14+
const {isDirty, dirtyFields} = useFormState();
15+
const isStatusDirty = isDirty && dirtyFields?.status;
16+
const status = watch('status');
17+
18+
return (
19+
<AdminSectionField error={errors.status?.message as string}>
20+
<label htmlFor="status">{__('Status', 'give')}</label>
21+
<div className={cx(styles.statusSelect, styles[`statusSelect--${status}`])}>
22+
<select id="status" className={styles.statusSelectInput} {...register('status')}>
23+
{statusOptions && (
24+
Object.entries(statusOptions).map(([value, label]) => (
25+
<option key={value} value={value}>
26+
{label as string}
27+
</option>
28+
))
29+
)}
30+
</select>
31+
</div>
32+
33+
{isStatusDirty && (
34+
<Notice type="info" className={styles.notice}>
35+
{__('This will not change the status at the gateway.', 'give')}
36+
</Notice>
37+
)}
38+
</AdminSectionField>
39+
);
40+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
2+
.statusSelect {
3+
--statusOptionColor: var(--givewp-grey-500);
4+
position: relative;
5+
6+
&::before {
7+
background-color: var(--statusOptionColor);
8+
border-radius: 50%;
9+
content: '';
10+
display: inline-block;
11+
height: 0.75rem;
12+
left: var(--givewp-spacing-4);
13+
pointer-events: none;
14+
position: absolute;
15+
top: 50%;
16+
transform: translateY(-50%);
17+
width: 0.75rem;
18+
}
19+
20+
&--pending,
21+
&--processing {
22+
--statusOptionColor: var(--givewp-blue-500);
23+
}
24+
25+
&--active,
26+
&--publish {
27+
--statusOptionColor: var(--givewp-green-500);
28+
}
29+
30+
&--completed {
31+
--statusOptionColor: var(--givewp-emerald-500);
32+
}
33+
34+
&--expired,
35+
&--cancelled,
36+
&--failing,
37+
&--failed,
38+
&--cancelled,
39+
&--revoked,
40+
&--trashed {
41+
--statusOptionColor: var(--givewp-red-500);
42+
}
43+
44+
select.statusSelectInput {
45+
padding-left: calc(var(--givewp-spacing-6) + 0.75rem);
46+
}
47+
}
48+
49+
.notice {
50+
margin-top: var(--givewp-spacing-1);
51+
transform: translateX(1px);
52+
}

0 commit comments

Comments
 (0)