Skip to content

Commit 7b5a04e

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

22 files changed

+664
-693
lines changed

packages/react/spec/auto/PolarisAutoForm.spec.tsx

+7-7
Original file line numberDiff line numberDiff line change
@@ -719,12 +719,6 @@ function loadMockGizmoCreateMetadata() {
719719
}
720720

721721
function loadMockWidgetCreateMetadata(opts?: { inputFields?: any[]; triggers?: any[] }) {
722-
expect(mockUrqlClient.executeQuery.mock.calls[0][0].variables).toEqual({
723-
modelApiIdentifier: "widget",
724-
modelNamespace: null,
725-
action: "create",
726-
});
727-
728722
mockUrqlClient.executeQuery.pushResponse("ModelActionMetadata", {
729723
stale: false,
730724
hasNext: false,
@@ -738,6 +732,12 @@ function loadMockWidgetCreateMetadata(opts?: { inputFields?: any[]; triggers?: a
738732
opts?.triggers
739733
),
740734
});
735+
736+
expect(mockUrqlClient.executeQuery.mock.calls[0][0].variables).toEqual({
737+
modelApiIdentifier: "widget",
738+
modelNamespace: null,
739+
action: "create",
740+
});
741741
}
742742

743743
function loadMockWidgetUpdateMetadata() {
@@ -747,8 +747,8 @@ function loadMockWidgetUpdateMetadata() {
747747

748748
function loadMockWidgetUpdateMetadataWithFindBy() {
749749
mockWidgetUpdateHelperFunctions.expectMetadataRequest();
750-
mockWidgetUpdateHelperFunctions.mockFindByResponse();
751750
mockWidgetUpdateHelperFunctions.mockMetadataResponse();
751+
mockWidgetUpdateHelperFunctions.mockFindByResponse();
752752
}
753753

754754
const mockWidgetUpdateHelperFunctions = {

packages/react/spec/auto/inputs/PolarisAutoTextInput.spec.tsx

+20-20
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,26 @@ const metadata = {
332332
};
333333

334334
const mockFindBy = () => {
335+
const updateMetadata = { ...metadata, action: { ...metadata.action, apiIdentifier: "update", operatesWithRecordIdentity: true } };
336+
mockUrqlClient.executeQuery.pushResponse("ModelActionMetadata", {
337+
stale: false,
338+
hasNext: false,
339+
data: {
340+
gadgetMeta: {
341+
modelAndRelatedModels: [
342+
{
343+
name: "Widget",
344+
apiIdentifier: "widget",
345+
fields: updateMetadata.action.inputFields,
346+
__typename: "GadgetModel",
347+
},
348+
],
349+
model: updateMetadata,
350+
__typename: "GadgetApplicationMeta",
351+
},
352+
},
353+
});
354+
335355
mockUrqlClient.executeQuery.pushResponse("widget", {
336356
stale: false,
337357
hasNext: false,
@@ -361,24 +381,4 @@ const mockFindBy = () => {
361381
},
362382
},
363383
});
364-
365-
const updateMetadata = { ...metadata, action: { ...metadata.action, apiIdentifier: "update", operatesWithRecordIdentity: true } };
366-
mockUrqlClient.executeQuery.pushResponse("ModelActionMetadata", {
367-
stale: false,
368-
hasNext: false,
369-
data: {
370-
gadgetMeta: {
371-
modelAndRelatedModels: [
372-
{
373-
name: "Widget",
374-
apiIdentifier: "widget",
375-
fields: updateMetadata.action.inputFields,
376-
__typename: "GadgetModel",
377-
},
378-
],
379-
model: updateMetadata,
380-
__typename: "GadgetApplicationMeta",
381-
},
382-
},
383-
});
384384
};

packages/react/spec/auto/support/helper.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,16 @@ export const mockWidgetFindBy = (
3030
action: Parameters<typeof getWidgetModelMetadata>[0],
3131
overridesRecord?: Parameters<typeof getWidgetRecord>[0]
3232
) => {
33-
mockUrqlClient.executeQuery.pushResponse("widget", {
33+
mockUrqlClient.executeQuery.pushResponse("ModelActionMetadata", {
3434
stale: false,
3535
hasNext: false,
36-
data: getWidgetRecord(overridesRecord),
36+
data: getWidgetModelMetadata(action),
3737
});
3838

39-
mockUrqlClient.executeQuery.pushResponse("ModelActionMetadata", {
39+
mockUrqlClient.executeQuery.pushResponse("widget", {
4040
stale: false,
4141
hasNext: false,
42-
data: getWidgetModelMetadata(action),
42+
data: getWidgetRecord(overridesRecord),
4343
});
4444
};
4545

packages/react/src/auto/AutoForm.ts

+75-11
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

+25-50
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
};

0 commit comments

Comments
 (0)