Skip to content

Commit d65bfcb

Browse files
committed
use a single select when using related models in AutoForm
1 parent af2b74e commit d65bfcb

19 files changed

+633
-662
lines changed

packages/react/src/auto/AutoForm.ts

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import type { ActionFunction, GadgetRecord, GlobalActionFunction } from "@gadgetinc/api-client-core";
1+
import type { ActionFunction, FieldSelection, GadgetRecord, GlobalActionFunction } from "@gadgetinc/api-client-core";
22
import { yupResolver } from "@hookform/resolvers/yup";
33
import type { ReactNode } from "react";
4-
import { useEffect, useMemo, useRef } from "react";
4+
import React, { useEffect, useMemo, useRef } from "react";
55
import type { AnyActionWithId, RecordIdentifier, UseActionFormHookStateData } from "src/use-action-form/types.js";
66
import type { GadgetObjectFieldConfig } from "../internal/gql/graphql.js";
77
import type { FieldMetadata, GlobalActionMetadata, ModelWithOneActionMetadata } from "../metadata.js";
8-
import { FieldType, filterAutoFormFieldList, isModelActionMetadata, useActionMetadata } from "../metadata.js";
8+
import { FieldType, buildAutoFormFieldList, isModelActionMetadata, useActionMetadata } from "../metadata.js";
9+
import { pathListToSelection } from "../use-table/helpers.js";
910
import type { FieldErrors, FieldValues } from "../useActionForm.js";
1011
import { useActionForm } from "../useActionForm.js";
1112
import { get, getFlattenedObjectKeys, type OptionsType } from "../utils.js";
@@ -16,6 +17,7 @@ import {
1617
validateTriggersFromApiClient,
1718
validateTriggersFromMetadata,
1819
} from "./AutoFormActionValidators.js";
20+
import { isAutoInput } from "./AutoInput.js";
1921

2022
/** The props that any <AutoForm/> component accepts */
2123
export type AutoFormProps<
@@ -89,22 +91,22 @@ export const useFormFields = (
8991
: [];
9092
const nonObjectFields = action.inputFields.filter((field) => field.configuration.__typename !== "GadgetObjectFieldConfig");
9193

92-
const includedRootLevelFields = filterAutoFormFieldList(nonObjectFields, options as any).map(
93-
(field) =>
94+
const includedRootLevelFields = buildAutoFormFieldList(nonObjectFields, options as any).map(
95+
([path, field]) =>
9496
({
95-
path: field.apiIdentifier,
97+
path,
9698
metadata: field,
9799
} as const)
98100
);
99101

100102
const includedObjectFields = objectFields.flatMap((objectField) =>
101-
filterAutoFormFieldList((objectField.configuration as unknown as GadgetObjectFieldConfig).fields as any, {
103+
buildAutoFormFieldList((objectField.configuration as unknown as GadgetObjectFieldConfig).fields as any, {
102104
...(options as any),
103105
isUpsertAction: true, // For upsert meta-actions, we allow IDs, and they are object fields instead of root level
104106
}).map(
105-
(innerField) =>
107+
([innerPath, innerField]) =>
106108
({
107-
path: `${objectField.apiIdentifier}.${innerField.apiIdentifier}`,
109+
path: `${objectField.apiIdentifier}.${innerPath}`,
108110
metadata: innerField,
109111
} as const)
110112
)
@@ -120,6 +122,19 @@ export const useFormFields = (
120122
}, [metadata, options]);
121123
};
122124

125+
export const useFormSelection = (
126+
modelApiIdentifier: string | undefined,
127+
fields: readonly { path: string; metadata: FieldMetadata }[]
128+
): FieldSelection | undefined => {
129+
if (!modelApiIdentifier) return;
130+
if (!fields.length) return;
131+
132+
const paths = fields.map((f) => f.path.replace(new RegExp(`^${modelApiIdentifier}\\.`), ""));
133+
const fieldMetaData = fields.map((f) => f.metadata);
134+
135+
return pathListToSelection(paths, fieldMetaData);
136+
};
137+
123138
const validateFormFieldApiIdentifierUniqueness = (actionApiIdentifier: string, inputApiIdentifiers: string[]) => {
124139
const seen = new Set<string>();
125140

@@ -142,7 +157,15 @@ export const useAutoForm = <
142157
>(
143158
props: AutoFormProps<GivenOptions, SchemaT, ActionFunc, any, any> & { findBy?: any }
144159
) => {
145-
const { action, record, onSuccess, onFailure, findBy } = props;
160+
const { action, record, onSuccess, onFailure, findBy, children } = props;
161+
162+
let include = props.include;
163+
let exclude = props.exclude;
164+
165+
if (children) {
166+
include = extractPathsFromChildren(children);
167+
exclude = undefined;
168+
}
146169

147170
validateNonBulkAction(action);
148171
validateTriggersFromApiClient(action);
@@ -152,12 +175,13 @@ export const useAutoForm = <
152175
validateTriggersFromMetadata(metadata);
153176

154177
// filter down the fields to render only what we want to render for this form
155-
const fields = useFormFields(metadata, props);
178+
const fields = useFormFields(metadata, { include, exclude });
156179
validateFindByObjectWithMetadata(fields, findBy);
157180
const isDeleteAction = metadata && isModelActionMetadata(metadata) && metadata.action.isDeleteAction;
158181
const isGlobalAction = action.type === "globalAction";
159182
const operatesWithRecordId = !!(metadata && isModelActionMetadata(metadata) && metadata.action.operatesWithRecordIdentity);
160183
const modelApiIdentifier = action.type == "action" ? action.modelApiIdentifier : undefined;
184+
const selection = useFormSelection(modelApiIdentifier, fields);
161185
const isUpsertMetaAction =
162186
metadata && isModelActionMetadata(metadata) && fields.some((field) => field.metadata.fieldType === FieldType.Id);
163187
const isUpsertWithFindBy = isUpsertMetaAction && !!findBy;
@@ -201,6 +225,8 @@ export const useAutoForm = <
201225
defaultValues: defaultValues as any,
202226
findBy: "findBy" in props ? props.findBy : undefined,
203227
throwOnInvalidFindByObject: false,
228+
pause: "findBy" in props ? fetchingMetadata : undefined,
229+
select: selection as any,
204230
resolver: useValidationResolver(metadata, fieldPathsToValidate),
205231
send: () => {
206232
const fieldsToSend = fields
@@ -282,6 +308,44 @@ export const useAutoForm = <
282308
};
283309
};
284310

311+
const extractPathsFromChildren = (children: React.ReactNode) => {
312+
const paths = new Set<string>();
313+
314+
React.Children.forEach(children, (child) => {
315+
if (React.isValidElement(child)) {
316+
const grandChildren = child.props.children as React.ReactNode | undefined;
317+
let childPaths: string[] = [];
318+
319+
if (grandChildren) {
320+
childPaths = extractPathsFromChildren(grandChildren);
321+
}
322+
323+
let field: string | undefined = undefined;
324+
325+
if (isAutoInput(child)) {
326+
const props = child.props as { field: string; selectPaths?: string[]; children?: React.ReactNode };
327+
field = props.field;
328+
329+
paths.add(field);
330+
331+
if (props.selectPaths && Array.isArray(props.selectPaths)) {
332+
props.selectPaths.forEach((selectPath) => {
333+
paths.add(`${field}.${selectPath}`);
334+
});
335+
}
336+
}
337+
338+
if (childPaths.length > 0) {
339+
for (const childPath of childPaths) {
340+
paths.add(field ? `${field}.${childPath}` : childPath);
341+
}
342+
}
343+
}
344+
});
345+
346+
return Array.from(paths);
347+
};
348+
285349
const removeIdFieldsUnlessUpsertWithoutFindBy = (isUpsertWithFindBy?: boolean) => {
286350
return (field: { metadata: FieldMetadata }) => {
287351
return field.metadata.fieldType === FieldType.Id ? !isUpsertWithFindBy : true;

packages/react/src/auto/hooks/useBelongsToInputController.tsx

Lines changed: 25 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,49 @@
1-
import { useCallback, useEffect, useMemo } from "react";
2-
import { useController, useFormContext } from "../../useActionForm.js";
3-
import { useAutoFormMetadata } from "../AutoFormContext.js";
1+
import { useCallback } from "react";
2+
import { useFormContext, useWatch } from "../../useActionForm.js";
43
import type { AutoRelationshipInputProps } from "../interfaces/AutoRelationshipInputProps.js";
54
import { useFieldMetadata } from "./useFieldMetadata.js";
6-
import { useRelatedModelOptions } from "./useRelatedModelOptions.js";
5+
import { useRelatedModelOptions } from "./useRelatedModel.js";
76

87
export const useBelongsToInputController = (props: AutoRelationshipInputProps) => {
9-
const { field, control } = props;
8+
const { field } = props;
109
const fieldMetadata = useFieldMetadata(field);
1110
const { path } = fieldMetadata;
12-
const { findBy } = useAutoFormMetadata();
11+
const { setValue } = useFormContext();
1312

1413
const relatedModelOptions = useRelatedModelOptions(props);
15-
const { selected, relatedModel } = relatedModelOptions;
14+
const { relatedModel } = relatedModelOptions;
1615

17-
const {
18-
formState: { defaultValues },
19-
} = useFormContext();
16+
const value = useWatch({ name: path });
2017

21-
const {
22-
field: fieldProps,
23-
fieldState: { error: fieldError },
24-
} = useController({
25-
name: path + ".id",
26-
control,
27-
});
18+
const selectedRecord: Record<string, any> | undefined = value?.id ? value : undefined;
2819

29-
const isLoading = selected.fetching || relatedModel.fetching;
30-
const errorMessage = fieldError?.message || selected.error?.message || relatedModel.error?.message;
20+
const isLoading = relatedModel.fetching;
21+
const errorMessage = relatedModel.error?.message;
3122

32-
const retrievedSelectedRecordId = useMemo(() => {
33-
return !selected.fetching && selected.records && selected.records.length ? selected.records[0][`${field}Id`] : null;
34-
}, [selected.fetching, selected.records]);
23+
const onSelectRecord = useCallback(
24+
(record: Record<string, any>) => {
25+
setValue(path, record);
26+
},
27+
[path, setValue]
28+
);
3529

36-
const selectedRelatedModelRecordMissing = useMemo(() => {
37-
if (!findBy) {
38-
// Without a find by, there is no retrieved record ID
39-
return false;
40-
}
41-
42-
return !selected.fetching && selected.records && selected.records.length
43-
? !selected.records[0].id && !relatedModel.records.map((r) => r.id).includes(fieldProps.value)
44-
: true;
45-
}, [findBy, selected.fetching, fieldProps.value, relatedModel.records, retrievedSelectedRecordId]);
46-
47-
useEffect(() => {
48-
// Initializing the controller with the selected record ID from the DB
49-
if (!selected.fetching && retrievedSelectedRecordId) {
50-
fieldProps.onChange(retrievedSelectedRecordId);
51-
}
52-
}, [selected.fetching, retrievedSelectedRecordId, defaultValues]);
30+
const onRemoveRecord = useCallback(() => {
31+
const { __typename, ...rest } = value;
5332

54-
const onSelectRecord = useCallback((recordId: string) => {
55-
fieldProps.onChange(recordId);
56-
}, []);
33+
const nullifiedRest = Object.keys(rest).reduce((acc, key) => {
34+
acc[key] = null;
35+
return acc;
36+
}, {} as Record<string, null>);
5737

58-
const onRemoveRecord = useCallback(() => {
59-
fieldProps.onChange(null);
60-
}, []);
38+
setValue(path, { ...nullifiedRest, id: null, __typename });
39+
}, [path, setValue, value]);
6140

6241
return {
6342
fieldMetadata,
6443
relatedModelOptions,
65-
6644
onSelectRecord,
6745
onRemoveRecord,
68-
69-
selectedRecordId: fieldProps.value,
70-
selectedRelatedModelRecordMissing,
71-
46+
selectedRecord,
7247
isLoading,
7348
errorMessage,
7449
};
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { useCallback, useMemo } from "react";
2+
import type { GadgetHasManyConfig } from "../../internal/gql/graphql.js";
3+
import { useFieldArray, useWatch } from "../../useActionForm.js";
4+
import type { AutoRelationshipInputProps } from "../interfaces/AutoRelationshipInputProps.js";
5+
import { useFieldMetadata } from "./useFieldMetadata.js";
6+
import { useRelatedModelOptions } from "./useRelatedModel.js";
7+
8+
export const useHasManyController = (props: AutoRelationshipInputProps) => {
9+
const { field } = props;
10+
const fieldMetadata = useFieldMetadata(field);
11+
const { path } = fieldMetadata;
12+
13+
const fieldArray = useFieldArray({ name: path, keyName: "_fieldArrayKey" });
14+
15+
const records: Record<string, any>[] = useWatch({ name: path, defaultValue: [] });
16+
17+
return {
18+
fieldMetadata,
19+
fieldArray,
20+
records,
21+
};
22+
};
23+
24+
export const useHasManyInputController = (props: AutoRelationshipInputProps) => {
25+
const { fieldMetadata, fieldArray, records } = useHasManyController(props);
26+
27+
const { metadata } = fieldMetadata;
28+
const inverseFieldApiIdentifier = useMemo(() => {
29+
return (metadata.configuration as GadgetHasManyConfig).inverseField?.apiIdentifier;
30+
}, [metadata.configuration]);
31+
32+
const { remove, append, update } = fieldArray;
33+
const relatedModelOptions = useRelatedModelOptions(props);
34+
35+
const { relatedModel } = relatedModelOptions;
36+
37+
const errorMessage = relatedModel.error?.message;
38+
const isLoading = relatedModel.fetching;
39+
40+
const selectedRecords = useMemo(() => {
41+
return (records ?? []).filter((value: { _unlink?: string }) => !("_unlink" in value && value._unlink));
42+
}, [records]);
43+
44+
const onRemoveRecord = useCallback(
45+
(record: Record<string, any>) => {
46+
const index = records.findIndex((value) => value.id === record.id);
47+
48+
if (index < 0) {
49+
return;
50+
}
51+
52+
if ("_link" in record) {
53+
remove(index);
54+
} else {
55+
update(index, {
56+
...record,
57+
_unlink: { id: record.id, inverseFieldApiIdentifier },
58+
});
59+
}
60+
},
61+
[inverseFieldApiIdentifier, records, remove, update]
62+
);
63+
64+
const onSelectRecord = useCallback(
65+
(record: Record<string, any>) => {
66+
const index = (records ?? []).findIndex((value) => value.id === record.id);
67+
68+
if (index >= 0) {
69+
const value = records[index];
70+
if ("_unlink" in value && value._unlink) {
71+
const { _unlink, ...rest } = value;
72+
update(index, rest);
73+
} else {
74+
onRemoveRecord(record);
75+
}
76+
} else {
77+
append({
78+
...record,
79+
_link: record.id,
80+
});
81+
}
82+
},
83+
[records, onRemoveRecord, update, append]
84+
);
85+
86+
return {
87+
fieldMetadata,
88+
relatedModelOptions,
89+
selectedRecords,
90+
errorMessage,
91+
isLoading,
92+
onSelectRecord,
93+
onRemoveRecord,
94+
};
95+
};

0 commit comments

Comments
 (0)