Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions frontend/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
extractDoisFromText,
getNanopubHash,
getUriEnd,
getUriFragment,
Expand Down Expand Up @@ -65,11 +66,14 @@
expect(isNanopubUri(noHashUri)).toBe(false);
});

it("should return false for URI where /np/ appears multiple times", () => {
it("should still return true for URI with suffix", () => {
// This tests the search behavior - it should find the first occurrence
const multiplePatternUri =
"https://w3id.org/np/RA1234567890abcdefghijklmnopqrstuvwxyzABCD/np/Ranother";
expect(isNanopubUri(multiplePatternUri)).toBe(false);
"https://w3id.org/np/RABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr/np/Ranother";
expect(isNanopubUri(multiplePatternUri)).toBe(true);
const hashsuffixPatternUri =
"https://w3id.org/np/RABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr#topic";
expect(isNanopubUri(hashsuffixPatternUri)).toBe(true);
});

it("should return true for URI with hash containing only letters", () => {
Expand Down Expand Up @@ -116,9 +120,9 @@
});

it("should handle edge case with undefined/invalid input", () => {
expect(isNanopubUri(undefined as any)).toBe(false);

Check warning on line 123 in frontend/__tests__/utils.test.ts

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
expect(isNanopubUri(null as any)).toBe(false);

Check warning on line 124 in frontend/__tests__/utils.test.ts

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
expect(isNanopubUri({} as any)).toBe(false);

Check warning on line 125 in frontend/__tests__/utils.test.ts

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
expect(isNanopubUri("")).toBe(false);
});

Expand All @@ -139,6 +143,7 @@
describe("getNanopubHash", () => {
// 43 chars
const hash = "abcdefghijklmno-qrstuvwxyzABCDEFGHIJKLMN_12";
const hash_alt = "qrstuvwxyzABCDEFGHIJKLMN-abcdefghijklmno_99";

it("should extract valid RA hash", () => {
const uri = `https://w3id.org/np/RA${hash}`;
Expand All @@ -160,13 +165,17 @@
expect(getNanopubHash(uri)).toBe(hash);
});

it("should return undefined if url is suffixed", () => {
it("should still work if url is suffixed", () => {
const uri1 = `https://w3id.org/np/FA${hash}/abc`;
expect(getNanopubHash(uri1)).toBeUndefined();
expect(getNanopubHash(uri1)).toBe(hash);
const uri2 = `https://w3id.org/np/FA${hash}#abc`;
expect(getNanopubHash(uri2)).toBeUndefined();
expect(getNanopubHash(uri2)).toBe(hash);
const uri3 = `https://w3id.org/np/FA${hash}/`;
expect(getNanopubHash(uri3)).toBeUndefined();
expect(getNanopubHash(uri3)).toBe(hash);
const uri4 = `https://w3id.org/np/RA${hash}/RA${hash_alt}`;
expect(getNanopubHash(uri4)).toBe(hash);
const uri5 = `https://w3id.org/np/RA${hash}/np/RA${hash_alt}`;
expect(getNanopubHash(uri5)).toBe(hash);
});

it("should return undefined for invalid hash length", () => {
Expand All @@ -177,9 +186,9 @@
});

it("should handle edge case with undefined/invalid input", () => {
expect(getNanopubHash(undefined as any)).toBeUndefined();

Check warning on line 189 in frontend/__tests__/utils.test.ts

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
expect(getNanopubHash(null as any)).toBeUndefined();

Check warning on line 190 in frontend/__tests__/utils.test.ts

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
expect(getNanopubHash({} as any)).toBeUndefined();

Check warning on line 191 in frontend/__tests__/utils.test.ts

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
expect(getNanopubHash("")).toBeUndefined();
});
});
Expand Down Expand Up @@ -498,3 +507,59 @@
});
});
});

describe("extractDoiFromText", () => {
it("should extract a valid DOI from text", () => {
const text = "See https://doi.org/10.1000/xyz123 for more info";
expect(extractDoisFromText(text)).toEqual(["10.1000/xyz123"]);
});

it("should extract DOI from text with trailing punctuation", () => {
const text = "See https://doi.org/10.1000/xyz123.";
expect(extractDoisFromText(text)).toEqual(["10.1000/xyz123"]);
});

it("should extract DOI with parentheses in suffix", () => {
const text = "See 10.1000/xyz(2023)45 for more info";
expect(extractDoisFromText(text)).toEqual(["10.1000/xyz(2023)45"]);
});

it("should extract DOI with underscores in suffix", () => {
const text = "See 10.1000/xyz_abc_123 for more info";
expect(extractDoisFromText(text)).toEqual(["10.1000/xyz_abc_123"]);
});

it("should extract DOI with hyphens in suffix", () => {
const text = "See 10.1000/xyz-abc-123 for more info";
expect(extractDoisFromText(text)).toEqual(["10.1000/xyz-abc-123"]);
});

it("should extract DOI from text with colon in suffix", () => {
const text = "See 10.1000/xyz:section for more info";
expect(extractDoisFromText(text)).toEqual(["10.1000/xyz:section"]);
});

it("should extract first DOI from text with multiple DOIs", () => {
const text =
"See 10.1000/first1, 10.1000/second2 and 10.1000/third3 for more info";
expect(extractDoisFromText(text)).toEqual([
"10.1000/first1",
"10.1000/second2",
"10.1000/third3",
]);
});

it("should extract 10.1002 DOI (Wiley format)", () => {
const text = "See 10.1002/anie.202312345 for more info";
expect(extractDoisFromText(text)).toEqual(["10.1002/anie.202312345"]);
});

it("should return empty array when no DOI found", () => {
const text = "This text contains no DOI";
expect(extractDoisFromText(text)).toEqual([]);
});

it("should return empty array for empty string", () => {
expect(extractDoisFromText("")).toEqual([]);
});
});
97 changes: 93 additions & 4 deletions frontend/src/components/formedible/fields/array-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@
// Create field config for each item
const createItemFieldConfig = useCallback(
(index: number) => {
const baseConfig: any = {

Check warning on line 143 in frontend/src/components/formedible/fields/array-field.tsx

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
name: `${name}[${index}]`,
type: itemType || "text",
label: itemLabel ? `${itemLabel} ${index + 1}` : undefined,
Expand Down Expand Up @@ -245,25 +245,114 @@
[itemIds, value, fieldApi, sortable],
);

// Track per-item touched state since these are mock field APIs
const [touchedItems, setTouchedItems] = useState<Set<number>>(
() => new Set(),
);

// Parse structured validation errors from the parent array field.
// The validator in use-formedible.tsx returns a JSON-encoded array of
// {path, message} objects for array fields so we can distribute errors
// to individual items and their sub-fields.
const parsedErrors = useMemo(() => {
const parentErrors = fieldApi.state?.meta?.errors as unknown[];
if (!parentErrors || parentErrors.length === 0) return {};

const errorsByItem: Record<
number,
{ itemErrors: string[]; subFieldErrors: Record<string, string[]> }
> = {};

for (const err of parentErrors) {
if (typeof err !== "string") continue;

// Try to parse as JSON array of structured issues
try {
const issues = JSON.parse(err) as Array<{
path: (string | number)[];
message: string;
}>;
if (Array.isArray(issues)) {
for (const issue of issues) {
if (!issue.path || issue.path.length === 0) continue;

const itemIndex =
typeof issue.path[0] === "number" ? issue.path[0] : -1;
if (itemIndex < 0) continue;

if (!errorsByItem[itemIndex]) {
errorsByItem[itemIndex] = {
itemErrors: [],
subFieldErrors: {},
};
}

if (issue.path.length > 1) {
// Sub-field error (e.g., path: [0, "cites"])
const subFieldName = String(issue.path[1]);
if (!errorsByItem[itemIndex].subFieldErrors[subFieldName]) {
errorsByItem[itemIndex].subFieldErrors[subFieldName] = [];
}
errorsByItem[itemIndex].subFieldErrors[subFieldName].push(
issue.message,
);
} else {
// Item-level error
errorsByItem[itemIndex].itemErrors.push(issue.message);
}
}
continue;
}
} catch {
// Not JSON — treat as a plain error string for the whole array
}
}

return errorsByItem;
}, [fieldApi.state?.meta?.errors]);

// Create a mock field API for each item
const createItemFieldApi = useCallback(
(index: number) => {
// Get item-level errors from parsed structured errors
const itemParsedErrors = parsedErrors[index];
const itemErrors = itemParsedErrors?.itemErrors || [];

// Item is touched if:
// - the user interacted with it directly (blur), OR
// - the form has been submitted (submissionAttempts > 0), OR
// - the parent array field is touched (e.g., from form validation)
const formSubmitted =
(fieldApi.form?.state as any)?.submissionAttempts > 0;

Check warning on line 326 in frontend/src/components/formedible/fields/array-field.tsx

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
const parentTouched = fieldApi.state?.meta?.isTouched ?? false;
const itemTouched =
touchedItems.has(index) || formSubmitted || parentTouched;

return {
name: `${name}[${index}]`,
state: {
value: value[index],
meta: {
errors: [],
isTouched: false,
errors: itemErrors,
isTouched: itemTouched,
isValidating: false,
// Attach sub-field errors so object-field can pick them up
_subFieldErrors: itemParsedErrors?.subFieldErrors || {},
},
},
handleChange: (newValue: unknown) => updateItem(index, newValue),
handleBlur: () => fieldApi.handleBlur(),
handleBlur: () => {
setTouchedItems((prev) => {
const next = new Set(prev);
next.add(index);
return next;
});
fieldApi.handleBlur();
},
form: fieldApi.form,
};
},
[name, value, updateItem, fieldApi],
[name, value, updateItem, fieldApi, touchedItems, parsedErrors],
);

const canAddMore = value.length < maxItems;
Expand All @@ -283,7 +372,7 @@
<div className="flex-1">
<NestedFieldRenderer
fieldConfig={createItemFieldConfig(draggedItemIndex)}
fieldApi={createItemFieldApi(draggedItemIndex) as any}

Check warning on line 375 in frontend/src/components/formedible/fields/array-field.tsx

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
form={fieldApi.form}
currentValues={
(value[draggedItemIndex] || {}) as Record<string, unknown>
Expand Down
56 changes: 51 additions & 5 deletions frontend/src/components/formedible/fields/object-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,57 @@ export const ObjectField: React.FC<ObjectFieldProps> = ({
return unsubscribe;
}, [fieldApi.form]);

// Track per-sub-field touched state
const [touchedFields, setTouchedFields] = React.useState<Set<string>>(
() => new Set(),
);

// Create a properly typed mockFieldApi that includes the form property
const createMockFieldApi = (fieldName: string, fieldValue: unknown) => {
const fullFieldName = `${fieldApi.name}.${fieldName}`;

// First, check for sub-field errors passed down from array-field
// via the _subFieldErrors property on the parent's meta
const parentSubFieldErrors = (fieldApi.state?.meta as any)
?._subFieldErrors as Record<string, string[]> | undefined;
let subFieldErrors: unknown[] = [];

if (parentSubFieldErrors && parentSubFieldErrors[fieldName]) {
subFieldErrors = parentSubFieldErrors[fieldName];
}

// Also check the form's fieldMeta for this specific sub-field
if (subFieldErrors.length === 0) {
const fieldMeta = fieldApi.form?.state?.fieldMeta;
if (fieldMeta) {
const subMeta = fieldMeta[fullFieldName as keyof typeof fieldMeta] as
| { errors?: unknown[] }
| undefined;
if (subMeta?.errors && subMeta.errors.length > 0) {
subFieldErrors = subMeta.errors;
}
}
}

// Sub-field is touched if:
// - the user interacted with it directly (blur), OR
// - the parent is touched (e.g., from array field or form validation), OR
// - the form has been submitted
const parentTouched = fieldApi.state?.meta?.isTouched ?? false;
const formSubmitted = (fieldApi.form?.state as any)?.submissionAttempts > 0;
const subFieldTouched =
touchedFields.has(fieldName) || parentTouched || formSubmitted;

return {
name: `${fieldApi.name}.${fieldName}`,
form: fieldApi.form, // Include the form property to fix the bug
name: fullFieldName,
form: fieldApi.form,
state: {
...fieldApi.state,
value: fieldValue,
meta: {
...fieldApi.state.meta,
errors: [], // Reset errors for subfield
isTouched: false, // Reset touched state for subfield
errors: subFieldErrors,
isTouched: subFieldTouched,
},
},
handleChange: (value: unknown) => {
Expand All @@ -51,7 +90,14 @@ export const ObjectField: React.FC<ObjectFieldProps> = ({
[fieldName]: value,
});
},
handleBlur: fieldApi.handleBlur,
handleBlur: () => {
setTouchedFields((prev) => {
const next = new Set(prev);
next.add(fieldName);
return next;
});
fieldApi.handleBlur();
},
};
};

Expand Down
20 changes: 17 additions & 3 deletions frontend/src/hooks/use-formedible.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1650,9 +1650,23 @@ export function useFormedible<TFormValues extends Record<string, unknown>>(
? {
onChange: ({ value }) => {
const result = validation.safeParse(value);
return result.success
? undefined
: result.error.issues[0]?.message || "Invalid value";
if (result.success) return undefined;

// For array fields with nested errors, return all issues
// with path info so array-field and object-field can
// display per-item/sub-field errors
if (type === "array" && result.error.issues.length > 0) {
// Return a JSON-encoded array of all issues with paths
// so the array field can parse and distribute them
return JSON.stringify(
result.error.issues.map((issue) => ({
path: issue.path,
message: issue.message,
})),
);
}

return result.error.issues[0]?.message || "Invalid value";
},
}
: undefined
Expand Down
Loading
Loading