From f6b1b4ac3bf2db642dcfb0c43648af55ee9f6139 Mon Sep 17 00:00:00 2001 From: Kyle Tate Date: Fri, 18 Oct 2024 11:38:38 -0400 Subject: [PATCH] use a single select when using related models in AutoForm --- .../auto/form/AutoFormFindByObjects.cy.tsx | 12 +- .../auto/form/AutoFormHasManyThrough.cy.tsx | 28 +- .../auto/form/AutoFormUpsertAction.cy.tsx | 2 +- packages/react/cypress/support/component.tsx | 1 + .../react/spec/auto/PolarisAutoForm.spec.tsx | 14 +- .../auto/inputs/PolarisAutoTextInput.spec.tsx | 40 +-- packages/react/spec/auto/support/helper.ts | 8 +- packages/react/src/auto/AutoForm.ts | 86 ++++- .../hooks/useBelongsToInputController.tsx | 75 ++--- .../src/auto/hooks/useHasManyController.tsx | 95 ++++++ .../auto/hooks/useHasManyInputController.tsx | 107 ------ .../auto/hooks/useHasOneInputController.tsx | 110 +++--- .../react/src/auto/hooks/useRelatedModel.tsx | 196 +++++++++++ .../src/auto/hooks/useRelatedModelOptions.tsx | 316 ------------------ .../relationships/MUIAutoBelongsToInput.tsx | 8 +- .../relationships/MUIAutoHasManyInput.tsx | 6 +- .../relationships/MUIAutoHasOneInput.tsx | 24 +- .../PolarisAutoBelongsToInput.tsx | 34 +- .../relationships/PolarisAutoHasManyInput.tsx | 30 +- .../relationships/PolarisAutoHasOneInput.tsx | 37 +- .../relationships/RelatedModelOptions.tsx | 15 +- .../SelectedRelatedRecordTags.tsx | 35 +- packages/react/src/metadata.tsx | 49 ++- packages/react/src/use-action-form/utils.ts | 11 +- packages/react/src/use-table/helpers.tsx | 56 ++++ packages/react/src/useActionForm.ts | 7 +- 26 files changed, 705 insertions(+), 697 deletions(-) create mode 100644 packages/react/src/auto/hooks/useHasManyController.tsx delete mode 100644 packages/react/src/auto/hooks/useHasManyInputController.tsx create mode 100644 packages/react/src/auto/hooks/useRelatedModel.tsx delete mode 100644 packages/react/src/auto/hooks/useRelatedModelOptions.tsx diff --git a/packages/react/cypress/component/auto/form/AutoFormFindByObjects.cy.tsx b/packages/react/cypress/component/auto/form/AutoFormFindByObjects.cy.tsx index da6c862ed..30b9b33f9 100644 --- a/packages/react/cypress/component/auto/form/AutoFormFindByObjects.cy.tsx +++ b/packages/react/cypress/component/auto/form/AutoFormFindByObjects.cy.tsx @@ -40,7 +40,12 @@ describeForEachAutoAdapter("AutoForm - FindBy object parameters", ({ name, adapt mainModel: { childModelEntries: null, nonUniqueString: "example", - uniqueBelongsTo: {}, + uniqueBelongsTo: { + update: { + id: "22", + parentUniqueString: "parent-example", + }, + }, uniqueEmail: "u2@email.com", uniqueString: "u2", }, @@ -411,6 +416,11 @@ const mainModelQueryDefaultValuesResponse = { nonUniqueString: "example", uniqueEmail: "u2@email.com", uniqueString: "u2", + uniqueBelongsTo: { + __typename: "UniqueFieldsParentModel", + id: "22", + parentUniqueString: "parent-example", + }, updatedAt: "2024-10-01T20:58:39.300Z", }, __typename: "UniqueFieldsMainModelEdge", diff --git a/packages/react/cypress/component/auto/form/AutoFormHasManyThrough.cy.tsx b/packages/react/cypress/component/auto/form/AutoFormHasManyThrough.cy.tsx index c95c152ac..364047ffb 100644 --- a/packages/react/cypress/component/auto/form/AutoFormHasManyThrough.cy.tsx +++ b/packages/react/cypress/component/auto/form/AutoFormHasManyThrough.cy.tsx @@ -20,7 +20,7 @@ describeForEachAutoAdapter("AutoForm - HasManyThrough fields", ({ name, adapter: it("does not render the hasMany->joinModel input field", () => { interceptModelActionMetadataRequest(); - cy.mountWithWrapper(, wrapper); + cy.mountWithWrapper(, wrapper); cy.wait("@ModelActionMetadata"); // Name field input is shown @@ -149,6 +149,32 @@ const modelActionMetadataResponse = { }, __typename: "GadgetModel", }, + { + key: "tJDsf_FvYqsi", + apiIdentifier: "joinerModel", + namespace: ["hasManyThrough"], + defaultDisplayField: { + name: "Id", + apiIdentifier: "id", + fieldType: "ID", + __typename: "GadgetModelField", + }, + fields: [], + __typename: "GadgetModel", + }, + { + key: "Oss4sCDW-DJU", + apiIdentifier: "siblingModel", + namespace: ["hasManyThrough"], + defaultDisplayField: { + name: "Id", + apiIdentifier: "id", + fieldType: "ID", + __typename: "GadgetModelField", + }, + fields: [], + __typename: "GadgetModel", + }, ], model: { name: "Base model", diff --git a/packages/react/cypress/component/auto/form/AutoFormUpsertAction.cy.tsx b/packages/react/cypress/component/auto/form/AutoFormUpsertAction.cy.tsx index a4d41c905..dcd151e29 100644 --- a/packages/react/cypress/component/auto/form/AutoFormUpsertAction.cy.tsx +++ b/packages/react/cypress/component/auto/form/AutoFormUpsertAction.cy.tsx @@ -147,7 +147,7 @@ describeForEachAutoAdapter("AutoForm - Upsert Action", ({ name, adapter: { AutoF cy.contains("Record Not Found Error: Gadget API returned no data at widget").should("exist"); }); - it("Can properly submit with custom form contents", () => { + it.only("Can properly submit with custom form contents", () => { mockSuccessfulWidgetFindBy(); mockSuccessfulUpsert(); diff --git a/packages/react/cypress/support/component.tsx b/packages/react/cypress/support/component.tsx index 924c170ec..1cbdcc113 100644 --- a/packages/react/cypress/support/component.tsx +++ b/packages/react/cypress/support/component.tsx @@ -67,6 +67,7 @@ before(() => { beforeEach(() => { cy.window().then((win) => { + if (!win) return; const mockToasts = win.document.getElementsByClassName("mock-toast"); while (mockToasts.length > 0) { try { diff --git a/packages/react/spec/auto/PolarisAutoForm.spec.tsx b/packages/react/spec/auto/PolarisAutoForm.spec.tsx index c2609f388..9f9d26117 100644 --- a/packages/react/spec/auto/PolarisAutoForm.spec.tsx +++ b/packages/react/spec/auto/PolarisAutoForm.spec.tsx @@ -719,12 +719,6 @@ function loadMockGizmoCreateMetadata() { } function loadMockWidgetCreateMetadata(opts?: { inputFields?: any[]; triggers?: any[] }) { - expect(mockUrqlClient.executeQuery.mock.calls[0][0].variables).toEqual({ - modelApiIdentifier: "widget", - modelNamespace: null, - action: "create", - }); - mockUrqlClient.executeQuery.pushResponse("ModelActionMetadata", { stale: false, hasNext: false, @@ -738,6 +732,12 @@ function loadMockWidgetCreateMetadata(opts?: { inputFields?: any[]; triggers?: a opts?.triggers ), }); + + expect(mockUrqlClient.executeQuery.mock.calls[0][0].variables).toEqual({ + modelApiIdentifier: "widget", + modelNamespace: null, + action: "create", + }); } function loadMockWidgetUpdateMetadata() { @@ -747,8 +747,8 @@ function loadMockWidgetUpdateMetadata() { function loadMockWidgetUpdateMetadataWithFindBy() { mockWidgetUpdateHelperFunctions.expectMetadataRequest(); - mockWidgetUpdateHelperFunctions.mockFindByResponse(); mockWidgetUpdateHelperFunctions.mockMetadataResponse(); + mockWidgetUpdateHelperFunctions.mockFindByResponse(); } const mockWidgetUpdateHelperFunctions = { diff --git a/packages/react/spec/auto/inputs/PolarisAutoTextInput.spec.tsx b/packages/react/spec/auto/inputs/PolarisAutoTextInput.spec.tsx index 39c008f71..41380a13a 100644 --- a/packages/react/spec/auto/inputs/PolarisAutoTextInput.spec.tsx +++ b/packages/react/spec/auto/inputs/PolarisAutoTextInput.spec.tsx @@ -332,6 +332,26 @@ const metadata = { }; const mockFindBy = () => { + const updateMetadata = { ...metadata, action: { ...metadata.action, apiIdentifier: "update", operatesWithRecordIdentity: true } }; + mockUrqlClient.executeQuery.pushResponse("ModelActionMetadata", { + stale: false, + hasNext: false, + data: { + gadgetMeta: { + modelAndRelatedModels: [ + { + name: "Widget", + apiIdentifier: "widget", + fields: updateMetadata.action.inputFields, + __typename: "GadgetModel", + }, + ], + model: updateMetadata, + __typename: "GadgetApplicationMeta", + }, + }, + }); + mockUrqlClient.executeQuery.pushResponse("widget", { stale: false, hasNext: false, @@ -361,24 +381,4 @@ const mockFindBy = () => { }, }, }); - - const updateMetadata = { ...metadata, action: { ...metadata.action, apiIdentifier: "update", operatesWithRecordIdentity: true } }; - mockUrqlClient.executeQuery.pushResponse("ModelActionMetadata", { - stale: false, - hasNext: false, - data: { - gadgetMeta: { - modelAndRelatedModels: [ - { - name: "Widget", - apiIdentifier: "widget", - fields: updateMetadata.action.inputFields, - __typename: "GadgetModel", - }, - ], - model: updateMetadata, - __typename: "GadgetApplicationMeta", - }, - }, - }); }; diff --git a/packages/react/spec/auto/support/helper.ts b/packages/react/spec/auto/support/helper.ts index c013be9d1..c8554733d 100644 --- a/packages/react/spec/auto/support/helper.ts +++ b/packages/react/spec/auto/support/helper.ts @@ -30,16 +30,16 @@ export const mockWidgetFindBy = ( action: Parameters[0], overridesRecord?: Parameters[0] ) => { - mockUrqlClient.executeQuery.pushResponse("widget", { + mockUrqlClient.executeQuery.pushResponse("ModelActionMetadata", { stale: false, hasNext: false, - data: getWidgetRecord(overridesRecord), + data: getWidgetModelMetadata(action), }); - mockUrqlClient.executeQuery.pushResponse("ModelActionMetadata", { + mockUrqlClient.executeQuery.pushResponse("widget", { stale: false, hasNext: false, - data: getWidgetModelMetadata(action), + data: getWidgetRecord(overridesRecord), }); }; diff --git a/packages/react/src/auto/AutoForm.ts b/packages/react/src/auto/AutoForm.ts index abc6e987e..a28f8b56f 100644 --- a/packages/react/src/auto/AutoForm.ts +++ b/packages/react/src/auto/AutoForm.ts @@ -1,11 +1,12 @@ -import type { ActionFunction, GadgetRecord, GlobalActionFunction } from "@gadgetinc/api-client-core"; +import type { ActionFunction, FieldSelection, GadgetRecord, GlobalActionFunction } from "@gadgetinc/api-client-core"; import { yupResolver } from "@hookform/resolvers/yup"; import type { ReactNode } from "react"; -import { useEffect, useMemo, useRef } from "react"; +import React, { useEffect, useMemo, useRef } from "react"; import type { AnyActionWithId, RecordIdentifier, UseActionFormHookStateData } from "src/use-action-form/types.js"; import type { GadgetObjectFieldConfig } from "../internal/gql/graphql.js"; import type { FieldMetadata, GlobalActionMetadata, ModelWithOneActionMetadata } from "../metadata.js"; -import { FieldType, filterAutoFormFieldList, isModelActionMetadata, useActionMetadata } from "../metadata.js"; +import { FieldType, buildAutoFormFieldList, isModelActionMetadata, useActionMetadata } from "../metadata.js"; +import { pathListToSelection } from "../use-table/helpers.js"; import type { FieldErrors, FieldValues } from "../useActionForm.js"; import { useActionForm } from "../useActionForm.js"; import { get, getFlattenedObjectKeys, type OptionsType } from "../utils.js"; @@ -16,6 +17,7 @@ import { validateTriggersFromApiClient, validateTriggersFromMetadata, } from "./AutoFormActionValidators.js"; +import { isAutoInput } from "./AutoInput.js"; /** The props that any component accepts */ export type AutoFormProps< @@ -89,22 +91,22 @@ export const useFormFields = ( : []; const nonObjectFields = action.inputFields.filter((field) => field.configuration.__typename !== "GadgetObjectFieldConfig"); - const includedRootLevelFields = filterAutoFormFieldList(nonObjectFields, options as any).map( - (field) => + const includedRootLevelFields = buildAutoFormFieldList(nonObjectFields, options as any).map( + ([path, field]) => ({ - path: field.apiIdentifier, + path, metadata: field, } as const) ); const includedObjectFields = objectFields.flatMap((objectField) => - filterAutoFormFieldList((objectField.configuration as unknown as GadgetObjectFieldConfig).fields as any, { + buildAutoFormFieldList((objectField.configuration as unknown as GadgetObjectFieldConfig).fields as any, { ...(options as any), isUpsertAction: true, // For upsert meta-actions, we allow IDs, and they are object fields instead of root level }).map( - (innerField) => + ([innerPath, innerField]) => ({ - path: `${objectField.apiIdentifier}.${innerField.apiIdentifier}`, + path: `${objectField.apiIdentifier}.${innerPath}`, metadata: innerField, } as const) ) @@ -120,6 +122,19 @@ export const useFormFields = ( }, [metadata, options]); }; +export const useFormSelection = ( + modelApiIdentifier: string | undefined, + fields: readonly { path: string; metadata: FieldMetadata }[] +): FieldSelection | undefined => { + if (!modelApiIdentifier) return; + if (!fields.length) return; + + const paths = fields.map((f) => f.path.replace(new RegExp(`^${modelApiIdentifier}\\.`), "")); + const fieldMetaData = fields.map((f) => f.metadata); + + return pathListToSelection(paths, fieldMetaData); +}; + const validateFormFieldApiIdentifierUniqueness = (actionApiIdentifier: string, inputApiIdentifiers: string[]) => { const seen = new Set(); @@ -142,7 +157,15 @@ export const useAutoForm = < >( props: AutoFormProps & { findBy?: any } ) => { - const { action, record, onSuccess, onFailure, findBy } = props; + const { action, record, onSuccess, onFailure, findBy, children } = props; + + let include = props.include; + let exclude = props.exclude; + + if (children) { + include = extractPathsFromChildren(children); + exclude = undefined; + } validateNonBulkAction(action); validateTriggersFromApiClient(action); @@ -152,12 +175,13 @@ export const useAutoForm = < validateTriggersFromMetadata(metadata); // filter down the fields to render only what we want to render for this form - const fields = useFormFields(metadata, props); + const fields = useFormFields(metadata, { include, exclude }); validateFindByObjectWithMetadata(fields, findBy); const isDeleteAction = metadata && isModelActionMetadata(metadata) && metadata.action.isDeleteAction; const isGlobalAction = action.type === "globalAction"; const operatesWithRecordId = !!(metadata && isModelActionMetadata(metadata) && metadata.action.operatesWithRecordIdentity); const modelApiIdentifier = action.type == "action" ? action.modelApiIdentifier : undefined; + const selection = useFormSelection(modelApiIdentifier, fields); const isUpsertMetaAction = metadata && isModelActionMetadata(metadata) && fields.some((field) => field.metadata.fieldType === FieldType.Id); const isUpsertWithFindBy = isUpsertMetaAction && !!findBy; @@ -201,6 +225,8 @@ export const useAutoForm = < defaultValues: defaultValues as any, findBy: "findBy" in props ? props.findBy : undefined, throwOnInvalidFindByObject: false, + pause: "findBy" in props ? fetchingMetadata : undefined, + select: selection as any, resolver: useValidationResolver(metadata, fieldPathsToValidate), send: () => { const fieldsToSend = fields @@ -282,6 +308,44 @@ export const useAutoForm = < }; }; +const extractPathsFromChildren = (children: React.ReactNode) => { + const paths = new Set(); + + React.Children.forEach(children, (child) => { + if (React.isValidElement(child)) { + const grandChildren = child.props.children as React.ReactNode | undefined; + let childPaths: string[] = []; + + if (grandChildren) { + childPaths = extractPathsFromChildren(grandChildren); + } + + let field: string | undefined = undefined; + + if (isAutoInput(child)) { + const props = child.props as { field: string; selectPaths?: string[]; children?: React.ReactNode }; + field = props.field; + + paths.add(field); + + if (props.selectPaths && Array.isArray(props.selectPaths)) { + props.selectPaths.forEach((selectPath) => { + paths.add(`${field}.${selectPath}`); + }); + } + } + + if (childPaths.length > 0) { + for (const childPath of childPaths) { + paths.add(field ? `${field}.${childPath}` : childPath); + } + } + } + }); + + return Array.from(paths); +}; + const removeIdFieldsUnlessUpsertWithoutFindBy = (isUpsertWithFindBy?: boolean) => { return (field: { metadata: FieldMetadata }) => { return field.metadata.fieldType === FieldType.Id ? !isUpsertWithFindBy : true; diff --git a/packages/react/src/auto/hooks/useBelongsToInputController.tsx b/packages/react/src/auto/hooks/useBelongsToInputController.tsx index dabb95154..d27734908 100644 --- a/packages/react/src/auto/hooks/useBelongsToInputController.tsx +++ b/packages/react/src/auto/hooks/useBelongsToInputController.tsx @@ -1,74 +1,49 @@ -import { useCallback, useEffect, useMemo } from "react"; -import { useController, useFormContext } from "../../useActionForm.js"; -import { useAutoFormMetadata } from "../AutoFormContext.js"; +import { useCallback } from "react"; +import { useFormContext, useWatch } from "../../useActionForm.js"; import type { AutoRelationshipInputProps } from "../interfaces/AutoRelationshipInputProps.js"; import { useFieldMetadata } from "./useFieldMetadata.js"; -import { useRelatedModelOptions } from "./useRelatedModelOptions.js"; +import { useRelatedModelOptions } from "./useRelatedModel.js"; export const useBelongsToInputController = (props: AutoRelationshipInputProps) => { - const { field, control } = props; + const { field } = props; const fieldMetadata = useFieldMetadata(field); const { path } = fieldMetadata; - const { findBy } = useAutoFormMetadata(); + const { setValue } = useFormContext(); const relatedModelOptions = useRelatedModelOptions(props); - const { selected, relatedModel } = relatedModelOptions; + const { relatedModel } = relatedModelOptions; - const { - formState: { defaultValues }, - } = useFormContext(); + const value = useWatch({ name: path }); - const { - field: fieldProps, - fieldState: { error: fieldError }, - } = useController({ - name: path + ".id", - control, - }); + const selectedRecord: Record | undefined = value?.id ? value : undefined; - const isLoading = selected.fetching || relatedModel.fetching; - const errorMessage = fieldError?.message || selected.error?.message || relatedModel.error?.message; + const isLoading = relatedModel.fetching; + const errorMessage = relatedModel.error?.message; - const retrievedSelectedRecordId = useMemo(() => { - return !selected.fetching && selected.records && selected.records.length ? selected.records[0][`${field}Id`] : null; - }, [selected.fetching, selected.records]); + const onSelectRecord = useCallback( + (record: Record) => { + setValue(path, record); + }, + [path, setValue] + ); - const selectedRelatedModelRecordMissing = useMemo(() => { - if (!findBy) { - // Without a find by, there is no retrieved record ID - return false; - } - - return !selected.fetching && selected.records && selected.records.length - ? !selected.records[0].id && !relatedModel.records.map((r) => r.id).includes(fieldProps.value) - : true; - }, [findBy, selected.fetching, fieldProps.value, relatedModel.records, retrievedSelectedRecordId]); - - useEffect(() => { - // Initializing the controller with the selected record ID from the DB - if (!selected.fetching && retrievedSelectedRecordId) { - fieldProps.onChange(retrievedSelectedRecordId); - } - }, [selected.fetching, retrievedSelectedRecordId, defaultValues]); + const onRemoveRecord = useCallback(() => { + const { __typename, ...rest } = value; - const onSelectRecord = useCallback((recordId: string) => { - fieldProps.onChange(recordId); - }, []); + const nullifiedRest = Object.keys(rest).reduce((acc, key) => { + acc[key] = null; + return acc; + }, {} as Record); - const onRemoveRecord = useCallback(() => { - fieldProps.onChange(null); - }, []); + setValue(path, { ...nullifiedRest, id: null, __typename }); + }, [path, setValue, value]); return { fieldMetadata, relatedModelOptions, - onSelectRecord, onRemoveRecord, - - selectedRecordId: fieldProps.value, - selectedRelatedModelRecordMissing, - + selectedRecord, isLoading, errorMessage, }; diff --git a/packages/react/src/auto/hooks/useHasManyController.tsx b/packages/react/src/auto/hooks/useHasManyController.tsx new file mode 100644 index 000000000..f568ea00f --- /dev/null +++ b/packages/react/src/auto/hooks/useHasManyController.tsx @@ -0,0 +1,95 @@ +import { useCallback, useMemo } from "react"; +import type { GadgetHasManyConfig } from "../../internal/gql/graphql.js"; +import { useFieldArray, useWatch } from "../../useActionForm.js"; +import type { AutoRelationshipInputProps } from "../interfaces/AutoRelationshipInputProps.js"; +import { useFieldMetadata } from "./useFieldMetadata.js"; +import { useRelatedModelOptions } from "./useRelatedModel.js"; + +export const useHasManyController = (props: AutoRelationshipInputProps) => { + const { field } = props; + const fieldMetadata = useFieldMetadata(field); + const { path } = fieldMetadata; + + const fieldArray = useFieldArray({ name: path, keyName: "_fieldArrayKey" }); + + const records: Record[] = useWatch({ name: path, defaultValue: [] }); + + return { + fieldMetadata, + fieldArray, + records, + }; +}; + +export const useHasManyInputController = (props: AutoRelationshipInputProps) => { + const { fieldMetadata, fieldArray, records } = useHasManyController(props); + + const { metadata } = fieldMetadata; + const inverseFieldApiIdentifier = useMemo(() => { + return (metadata.configuration as GadgetHasManyConfig).inverseField?.apiIdentifier; + }, [metadata.configuration]); + + const { remove, append, update } = fieldArray; + const relatedModelOptions = useRelatedModelOptions(props); + + const { relatedModel } = relatedModelOptions; + + const errorMessage = relatedModel.error?.message; + const isLoading = relatedModel.fetching; + + const selectedRecords = useMemo(() => { + return (records ?? []).filter((value: { _unlink?: string }) => !("_unlink" in value && value._unlink)); + }, [records]); + + const onRemoveRecord = useCallback( + (record: Record) => { + const index = records.findIndex((value) => value.id === record.id); + + if (index < 0) { + return; + } + + if ("_link" in record) { + remove(index); + } else { + update(index, { + ...record, + _unlink: { id: record.id, inverseFieldApiIdentifier }, + }); + } + }, + [inverseFieldApiIdentifier, records, remove, update] + ); + + const onSelectRecord = useCallback( + (record: Record) => { + const index = (records ?? []).findIndex((value) => value.id === record.id); + + if (index >= 0) { + const value = records[index]; + if ("_unlink" in value && value._unlink) { + const { _unlink, ...rest } = value; + update(index, rest); + } else { + onRemoveRecord(record); + } + } else { + append({ + ...record, + _link: record.id, + }); + } + }, + [records, onRemoveRecord, update, append] + ); + + return { + fieldMetadata, + relatedModelOptions, + selectedRecords, + errorMessage, + isLoading, + onSelectRecord, + onRemoveRecord, + }; +}; diff --git a/packages/react/src/auto/hooks/useHasManyInputController.tsx b/packages/react/src/auto/hooks/useHasManyInputController.tsx deleted file mode 100644 index 6618da835..000000000 --- a/packages/react/src/auto/hooks/useHasManyInputController.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useCallback, useEffect, useMemo } from "react"; -import type { GadgetHasManyConfig } from "../../internal/gql/graphql.js"; -import { useFieldArray, useFormContext } from "../../useActionForm.js"; -import { uniq } from "../../utils.js"; -import type { AutoRelationshipInputProps } from "../interfaces/AutoRelationshipInputProps.js"; -import { useFieldMetadata } from "./useFieldMetadata.js"; -import { useRelatedModelOptions } from "./useRelatedModelOptions.js"; - -export const useHasManyInputController = (props: AutoRelationshipInputProps) => { - const { field } = props; - const { getValues } = useFormContext(); - const fieldMetadata = useFieldMetadata(field); - const { metadata, path } = fieldMetadata; - const inverseFieldApiIdentifier = useMemo(() => { - return (metadata.configuration as GadgetHasManyConfig).inverseField?.apiIdentifier; - }, [metadata.configuration]); - - const { fields, remove, append } = useFieldArray({ name: path }); - const clearAllFields = useCallback(async () => remove(), []); - - const relatedModelOptions = useRelatedModelOptions(props); - const { selected, relatedModel } = relatedModelOptions; - - const errorMessage = relatedModel.error?.message ?? selected.error?.message; - const isLoading = relatedModel.fetching || selected.fetching; - - const retrievedSelectedRecordIds = selected.records?.map((record: { id: string }) => record.id) ?? []; - const unlinkedRecordIds = fields.filter((field: any) => field.__unlinkedInverseField).map((field: any) => field.__id); - const formContextValue = getValues(path); - - useEffect(() => { - if (!formContextValue) { - void clearAllFields(); // This is called asynchronously to avoid an infinite loop - } - }, [!formContextValue || formContextValue.length === 0]); - - const selectedRecordIds = uniq( - [ - ...fields.map((field, i) => (field as any).__id), // To be selected upon submit - ...retrievedSelectedRecordIds, // From related model records in DB - ].filter((id) => !unlinkedRecordIds.includes(id)) - ); - - const removeFromFieldsByRecordId = useCallback( - (recordId: string) => { - const index = fields.findIndex((entry) => (entry as any).__id === recordId); - if (index > -1) { - remove(index); - } - }, - [fields.map((field) => (field as any).__id).join(",")] - ); - - const onRemoveRecord = useCallback( - (recordId: string) => { - const isSelectedInBackend = retrievedSelectedRecordIds.includes(recordId); - - if (isSelectedInBackend) { - append({ - id: recordId, - __id: recordId, - __unlinkedInverseField: inverseFieldApiIdentifier!, - }); - } else { - // Only selected in frontend - removeFromFieldsByRecordId(recordId); - } - }, - [inverseFieldApiIdentifier, retrievedSelectedRecordIds] - ); - - const onSelectRecord = useCallback( - (recordId: string) => { - const isAlreadySelected = selectedRecordIds.includes(recordId); - if (isAlreadySelected) { - onRemoveRecord(recordId); - return; - } - - if (unlinkedRecordIds.includes(recordId)) { - // Re-linking a record that is - // retrievedFromBackend -> removedInFrontend -> reselectedInFrontend - removeFromFieldsByRecordId(recordId); - } else { - // Adding a new record that was not previously selected - append({ - id: recordId, - __id: recordId, // TODO - Investigate utilization of `getValues()` to potentially avoid this __id system - }); - } - }, - [selectedRecordIds, unlinkedRecordIds, inverseFieldApiIdentifier, onRemoveRecord] - ); - - return { - fieldMetadata, - relatedModelOptions, - - selectedRecordIds, - - errorMessage, - isLoading, - - onSelectRecord, - onRemoveRecord, - }; -}; diff --git a/packages/react/src/auto/hooks/useHasOneInputController.tsx b/packages/react/src/auto/hooks/useHasOneInputController.tsx index 9a44d62dd..31b036b43 100644 --- a/packages/react/src/auto/hooks/useHasOneInputController.tsx +++ b/packages/react/src/auto/hooks/useHasOneInputController.tsx @@ -1,101 +1,79 @@ import { useCallback, useMemo } from "react"; import type { GadgetHasOneConfig } from "../../internal/gql/graphql.js"; -import { useFieldArray } from "../../useActionForm.js"; -import { uniq } from "../../utils.js"; +import { useController } from "../../useActionForm.js"; import type { AutoRelationshipInputProps } from "../interfaces/AutoRelationshipInputProps.js"; import { useFieldMetadata } from "./useFieldMetadata.js"; -import { useRelatedModelOptions } from "./useRelatedModelOptions.js"; +import { useRelatedModelOptions } from "./useRelatedModel.js"; export const useHasOneInputController = (props: AutoRelationshipInputProps) => { - const { field } = props; + const { field, control } = props; const fieldMetadata = useFieldMetadata(field); const { metadata, path } = fieldMetadata; - const inverseFieldApiIdentifier = useMemo(() => { - return (metadata.configuration as GadgetHasOneConfig).inverseField?.apiIdentifier; - }, [metadata.configuration]); - - const { fields, remove, append, replace } = useFieldArray({ - /** - * Currently, directly using the path will break the submit button. - * This feels like a good way to store the state of the hasOne selection, but hasOne fields can't send with array values - */ - name: fieldMetadata.path + "__RemoveOnceWeUpdateHasOneApiToMaintainOneToOneRelationships", + const { + field: fieldProps, + fieldState: { error: fieldError }, + } = useController({ + name: path, + control, }); - const relatedModelOptions = useRelatedModelOptions(props); - const { options, selected, pagination, search, relatedModel } = relatedModelOptions; - const errorMessage = relatedModel.error?.message ?? selected.error?.message; - const isLoading = relatedModel.fetching || selected.fetching; + const value: Record | undefined = fieldProps.value; - const retrievedSelectedRecordIds = selected.records?.map((record: { id: string }) => record.id) ?? []; - const unlinkedRecordIds = fields.filter((field: any) => field.__unlinkedInverseField).map((field: any) => field.__id); + const selectedRecord = useMemo(() => { + if (!value) { + return undefined; + } - const selectedRecordIds = uniq( - [ - ...fields.map((field, i) => (field as any).__id), // To be selected upon submit - ...retrievedSelectedRecordIds, // From related model records in DB - ].filter((id) => !unlinkedRecordIds.includes(id)) - ); + return "_unlink" in value && value._unlink ? undefined : value; + }, [value]); - const removeFromFieldsByRecordId = useCallback( - (recordId: string) => { - const index = fields.findIndex((entry) => (entry as any).__id === recordId); - if (index > -1) { - remove(index); - } - }, - [fields] - ); + const inverseFieldApiIdentifier = useMemo(() => { + return (metadata.configuration as GadgetHasOneConfig).inverseField?.apiIdentifier; + }, [metadata.configuration]); + + const relatedModelOptions = useRelatedModelOptions(props); + const { relatedModel } = relatedModelOptions; - const getUnselectedExistingRetrievedRecordsFieldValues = (excludedId?: string) => - retrievedSelectedRecordIds - .filter((recordId) => recordId !== excludedId) - .map((recordId) => ({ __id: recordId, __unlinkedInverseField: inverseFieldApiIdentifier! })); + const errorMessage = fieldError?.message || relatedModel.error?.message; + const isLoading = relatedModel.fetching; const onSelectRecord = useCallback( - (recordId: string) => { - const isAlreadySelected = selectedRecordIds.includes(recordId); - if (isAlreadySelected) { - onRemoveRecord(recordId); - return; + (record: Record) => { + const isAlreadySelected = value?.id === record.id; + + if (value && isAlreadySelected) { + if ("_unlink" in value && value._unlink) { + const { _unlink, ...rest } = value; + fieldProps.onChange(rest); + } else { + fieldProps.onChange({ ...value, _unlink: { id: record.id, inverseFieldApiIdentifier } }); + } + } else { + fieldProps.onChange({ ...record, _link: record.id }); } - - const isRetrievedValueReselect = retrievedSelectedRecordIds.includes(recordId); - - replace( - isRetrievedValueReselect - ? getUnselectedExistingRetrievedRecordsFieldValues(recordId) - : [{ __id: recordId }, ...getUnselectedExistingRetrievedRecordsFieldValues()] - ); }, - [retrievedSelectedRecordIds, selectedRecordIds] + [value, inverseFieldApiIdentifier, fieldProps] ); const onRemoveRecord = useCallback( - (recordId: string) => { - const isSelectedInBackend = retrievedSelectedRecordIds.includes(recordId); - - if (isSelectedInBackend) { - append({ __id: recordId, __unlinkedInverseField: inverseFieldApiIdentifier! }); + (record: Record) => { + if (value && "_unlink" in value && value._unlink) { + const { _unlink, ...rest } = value; + fieldProps.onChange(rest); } else { - // Only selected in frontend - removeFromFieldsByRecordId(recordId); + fieldProps.onChange({ ...value, _unlink: { id: record.id, inverseFieldApiIdentifier } }); } }, - [retrievedSelectedRecordIds, inverseFieldApiIdentifier] + [value, inverseFieldApiIdentifier, fieldProps] ); return { fieldMetadata, - relatedModelOptions, - - selectedRecordIds, - + selectedRecord, errorMessage, isLoading, - onSelectRecord, onRemoveRecord, }; diff --git a/packages/react/src/auto/hooks/useRelatedModel.tsx b/packages/react/src/auto/hooks/useRelatedModel.tsx new file mode 100644 index 000000000..8aca08245 --- /dev/null +++ b/packages/react/src/auto/hooks/useRelatedModel.tsx @@ -0,0 +1,196 @@ +import { assert, type FieldSelection } from "@gadgetinc/api-client-core"; +import { useCallback, useEffect, useState } from "react"; +import { useFindMany } from "../../useFindMany.js"; +import { sortByProperty, uniqByProperty } from "../../utils.js"; +import type { OptionLabel } from "../interfaces/AutoRelationshipInputProps.js"; +import type { RelationshipFieldConfig } from "../interfaces/RelationshipFieldConfig.js"; +import { useFieldMetadata } from "./useFieldMetadata.js"; +import { useModelManager } from "./useModelManager.js"; + +export const optionRecordsToLoadCount = 25; +export const selectedRecordsToLoadCount = 25; + +export const useRelatedModelRecords = (props: { field: string; optionLabel?: OptionLabel }) => { + const { field } = props; + const { metadata } = useFieldMetadata(field); + + const relationshipFieldConfig = metadata.configuration as RelationshipFieldConfig; + + const relatedModelApiIdentifier = relationshipFieldConfig.relatedModel?.apiIdentifier; + const relatedModelNamespace = relationshipFieldConfig.relatedModel?.namespace; + + const relatedModelRecords = useAllRelatedModelRecords({ + relatedModel: { apiIdentifier: relatedModelApiIdentifier!, namespace: relatedModelNamespace }, + }); + + return { + relatedModelRecords, + }; +}; + +export const useOptionLabelForField = (field: string, optionLabel?: OptionLabel): OptionLabel => { + const { metadata } = useFieldMetadata(field); + const relationshipFieldConfig = metadata.configuration as RelationshipFieldConfig; + + return assert( + optionLabel ?? relationshipFieldConfig.relatedModel?.defaultDisplayField.apiIdentifier, + "Option label is required for relationships" + ); +}; + +export const useRelatedModelOptions = (props: { + field: string; // Field API identifier + optionLabel?: OptionLabel; // The label to display for each related model record +}) => { + const { field } = props; + + const optionLabel = useOptionLabelForField(field, props.optionLabel); + const { relatedModelRecords } = useRelatedModelRecords(props); + + const { relatedModel, pagination, search } = relatedModelRecords; + + const getOptions = () => { + const options = uniqByProperty(getRecordsAsOptions(relatedModel.records, optionLabel), "id"); + + return options; + }; + + const [options, setOptions] = useState(getOptions()); + const recordIds = getRecordIdsAsString(relatedModel.records); + + useEffect(() => { + if (relatedModel.fetching) { + return; + } + + setOptions(getOptions()); + }, [relatedModel.fetching, recordIds]); + + return { + options, + searchFilterOptions: options.filter((option) => { + return search.value ? `${option.label}`.toLowerCase().includes(search.value.toLowerCase()) : true; + }), + relatedModel, + pagination, + search, + }; +}; + +export const getRecordLabel = (record: Record, optionLabel: OptionLabel): string => + typeof optionLabel === "string" + ? record[optionLabel] // Related model field API id + : Array.isArray(optionLabel) + ? optionLabel.map((fieldName) => record[fieldName]).join(" ") + : optionLabel(record); // Callback on the whole related model record + +const getRecordIdsAsString = (records?: { map: (mapperFunction: (record: { id: string }) => string) => string[] }) => + records + ?.map((record) => record.id) + .sort() + .join(","); + +export const getRecordAsOption = (record: Record, optionLabel: OptionLabel) => { + return { + id: record.id, + label: getRecordLabel(record, optionLabel), + }; +}; + +export const getRecordsAsOptions = (records: Record[], optionLabel: OptionLabel) => { + return records?.map((record: Record) => getRecordAsOption(record, optionLabel)) ?? []; +}; + +const useAllRelatedModelRecords = (props: { + optionLabel?: OptionLabel; + filter?: Record; + relatedModel: { apiIdentifier: string; namespace?: string[] | string | null }; +}) => { + const { optionLabel, relatedModel } = props; + + let optionLabelSelection: FieldSelection | undefined = undefined; + + if (optionLabel && typeof optionLabel === "string") { + optionLabelSelection = { id: true, [optionLabel]: true }; + } else if (optionLabel && Array.isArray(optionLabel)) { + optionLabelSelection = optionLabel.reduce( + (acc, fieldName) => { + acc[fieldName] = true; + return acc; + }, + { id: true } as FieldSelection + ); + } + + const relatedModelManager = useModelManager(relatedModel); + + const [loadedRecords, setLoadedRecords] = useState([]); + const [paginationPage, setPaginationPage] = useState(undefined); + const [searchValue, setSearchValue] = useState(); + + const [{ data: newlyFetchedRecords, fetching, error }, _refetch] = useFindMany(relatedModelManager as any, { + first: optionRecordsToLoadCount, + ...(props.filter && { filter: props.filter }), + ...(paginationPage && { after: paginationPage }), + ...(searchValue && { search: searchValue }), + ...(optionLabelSelection && { select: optionLabelSelection }), + }); + + const hasNextPage = !!newlyFetchedRecords?.hasNextPage; + + const clearPagination = useCallback(() => setPaginationPage(undefined), []); + + const loadNextPage = useCallback(() => { + const canFetchNextPage = + newlyFetchedRecords && newlyFetchedRecords.length >= optionRecordsToLoadCount && hasNextPage && newlyFetchedRecords.endCursor; + + if (canFetchNextPage) { + setPaginationPage(newlyFetchedRecords.endCursor); + } + }, [newlyFetchedRecords]); + + const setSearch = useCallback((search?: string) => { + clearPagination(); + const emptySearch = search === ""; + setSearchValue(emptySearch ? undefined : search); + }, []); + + /** + * This useEffect appends the newly fetched records to the list of records that have already been loaded + * `numberOfRecordsToLoad` are retrieved per `useFindMany` call + */ + useEffect(() => { + if (fetching || !newlyFetchedRecords) { + return; + } + + const allOptions = [ + ...loadedRecords, // Maintain existing options + ...newlyFetchedRecords.map((record) => record), + ]; + + const updatedUniqueOptions = uniqByProperty(allOptions, "id"); + const sortedUniqueOptions = sortByProperty(updatedUniqueOptions, "id"); + + setLoadedRecords(sortedUniqueOptions); + }, [paginationPage, searchValue, fetching]); + + return { + relatedModel: { + records: loadedRecords, + error, + fetching, + }, + + pagination: { + clearPagination, + loadNextPage, + hasNextPage, + }, + + search: { + value: searchValue, + set: setSearch, + }, + }; +}; diff --git a/packages/react/src/auto/hooks/useRelatedModelOptions.tsx b/packages/react/src/auto/hooks/useRelatedModelOptions.tsx deleted file mode 100644 index 60ac430fb..000000000 --- a/packages/react/src/auto/hooks/useRelatedModelOptions.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import { assert } from "@gadgetinc/api-client-core"; -import { useCallback, useEffect, useState } from "react"; -import { FieldType } from "../../metadata.js"; -import type { RecordIdentifier } from "../../use-action-form/types.js"; -import { useFindExistingRecord } from "../../use-action-form/utils.js"; -import { useFindMany } from "../../useFindMany.js"; -import { sortByProperty, uniqByProperty } from "../../utils.js"; -import { useAutoFormMetadata } from "../AutoFormContext.js"; -import type { OptionLabel } from "../interfaces/AutoRelationshipInputProps.js"; -import type { RelationshipFieldConfig } from "../interfaces/RelationshipFieldConfig.js"; -import { useFieldMetadata } from "./useFieldMetadata.js"; -import { useModelManager } from "./useModelManager.js"; - -export const optionRecordsToLoadCount = 25; -export const selectedRecordsToLoadCount = 25; - -export const useRelatedModelOptions = (props: { - field: string; // Field API identifier - optionLabel?: OptionLabel; // The label to display for each related model record -}) => { - const { field } = props; - const { metadata } = useFieldMetadata(field); - const { findBy, model } = useAutoFormMetadata(); - - const isBelongsToField = metadata.configuration.fieldType === FieldType.BelongsTo; - const relationshipFieldConfig = metadata.configuration as RelationshipFieldConfig; - - const relatedModelApiIdentifier = relationshipFieldConfig.relatedModel?.apiIdentifier; - const relatedModelNamespace = relationshipFieldConfig.relatedModel?.namespace; - const relatedModelInverseFieldApiId = - "inverseField" in relationshipFieldConfig ? relationshipFieldConfig.inverseField?.apiIdentifier : undefined; - - const optionLabel = assert( - props.optionLabel ?? relationshipFieldConfig.relatedModel?.defaultDisplayField.apiIdentifier, - "Option label is required for relationships" - ); - - const { selected } = isBelongsToField - ? // eslint-disable-next-line - useLinkedChildModelRelatedModelRecords({ - belongsToFieldApiId: field, - findBy, - currentModel: { apiIdentifier: model!.apiIdentifier!, namespace: model!.namespace }, - }) - : // eslint-disable-next-line - useLinkedParentModelRelatedModelRecords({ - findBy, - currentModel: { apiIdentifier: model!.apiIdentifier!, namespace: model!.namespace }, - relatedModel: { - apiIdentifier: relatedModelApiIdentifier!, - namespace: relatedModelNamespace, - inverseFieldApiIdentifier: relatedModelInverseFieldApiId!, - }, - }); - - const relatedModelRecords = useAllRelatedModelRecords({ - relatedModel: { apiIdentifier: relatedModelApiIdentifier!, namespace: relatedModelNamespace }, - optionLabel, - }); - const { relatedModel, pagination, search } = relatedModelRecords; - - const getOptions = () => { - const options = uniqByProperty( - [ - ...getRecordsAsOptions(selected.records ?? [], optionLabel), // Selected records - ...getRecordsAsOptions(relatedModel.records, optionLabel), // All related model records - ], - "id" - ); - - return options; - }; - - const [options, setOptions] = useState(getOptions()); - - useEffect(() => { - if (selected.fetching || relatedModel.fetching) { - return; - } - - setOptions(getOptions()); - }, [selected.fetching, relatedModel.fetching, getRecordIdsAsString(selected.records), getRecordIdsAsString(relatedModel.records)]); - - return { - options, - searchFilterOptions: options.filter((option) => { - return search.value ? `${option.label}`.toLowerCase().includes(search.value.toLowerCase()) : true; - }), - - selected, - - relatedModel, - pagination, - search, - }; -}; - -const getRecordIdsAsString = (records?: { map: (mapperFunction: (record: { id: string }) => string) => string[] }) => - records - ?.map((record) => record.id) - .sort() - .join(","); - -export const getRecordsAsOptions = (records: Record[], optionLabel: OptionLabel) => { - const getRecordLabel = (record: Record, optionLabel: OptionLabel): string => - typeof optionLabel === "string" - ? record[optionLabel] // Related model field API id - : optionLabel(record); // Callback on the whole related model record - - return ( - records?.map((record: Record) => ({ - id: record.id, - label: getRecordLabel(record, optionLabel), - })) ?? [] - ); -}; - -/** - * For getting the selected record in a BelongsTo relationship - * Returns the selected record in an array for interoperability with the HasOne/HasMany hook - * - * The lookup is done using the `findBy` to lookup on the current model to retrieve the related model record data - */ -export const useLinkedChildModelRelatedModelRecords = (props: { - belongsToFieldApiId: string; - findBy?: RecordIdentifier; - currentModel: { apiIdentifier: string; namespace?: string[] | string | null }; -}) => { - const { findBy, belongsToFieldApiId, currentModel } = props; - - const modelManager = useModelManager(currentModel); - - const [{ data: selectedRecord, fetching: fetchingSelected, error: fetchSelectedRecordError }] = useFindExistingRecord( - modelManager, - findBy ?? "", - { - pause: !findBy, // BelongsTo needs a selected record to query in the related model - select: { - id: true, - [`${belongsToFieldApiId}Id`]: true, // Retrieve the raw field value, regardless of if the ID exists or not - [belongsToFieldApiId]: { _all: true }, // All of the fields on the related record iff the record exists - }, - } - ); - - return { - selected: { - records: selectedRecord - ? [ - { - ...selectedRecord[belongsToFieldApiId]?._all, - [`${belongsToFieldApiId}Id`]: selectedRecord[`${belongsToFieldApiId}Id`], - }, - ] - : undefined, - fetching: fetchingSelected, - error: fetchSelectedRecordError, - }, - }; -}; - -/** - * For getting the related child model records in a HasOne/HasMany relationship - */ -export const useLinkedParentModelRelatedModelRecords = (props: { - currentModel: { - apiIdentifier: string; - namespace?: string[] | string | null; - }; - relatedModel: { - apiIdentifier: string; - namespace?: string[] | string | null; - inverseFieldApiIdentifier: string; - }; - findBy?: RecordIdentifier; -}) => { - const { currentModel, relatedModel, findBy } = props; - const { currentRecordId, fetchingCurrentRecord } = useCurrentRecordId({ currentModel, findBy }); - - const relatedModelManager = useModelManager(relatedModel); - - const { apiIdentifier, inverseFieldApiIdentifier } = relatedModel; - if (!inverseFieldApiIdentifier) { - throw new Error( - `The inverse field api identifier is invalid for the related model "${apiIdentifier}" in the useLinkedParentModelRelatedModelRecords hook.` - ); - } - - const filterField = `${inverseFieldApiIdentifier}Id`; // Filter on the `Id` suffixed inverse field for compatibility before and after framework version v1.3 - - const [{ data: selectedRecords, fetching: fetchingSelected, error: fetchSelectedRecordError }] = useFindMany(relatedModelManager as any, { - pause: !currentRecordId || fetchingCurrentRecord, // HasOne/HasMany need the current record to query the inverse field in the related model - - first: selectedRecordsToLoadCount, // Many records can point to the current record in hasOne/hasMany - filter: { [filterField]: { equals: currentRecordId } }, // Filter by the inverse field belongsTo field value - }); - - return { - selected: { - records: selectedRecords, - fetching: fetchingSelected, - error: fetchSelectedRecordError, - }, - }; -}; - -const useCurrentRecordId = (props: { - currentModel: { - apiIdentifier: string; - namespace?: string[] | string | null; - }; - findBy?: RecordIdentifier; -}) => { - const { currentModel, findBy } = props; - - const findByAsIdString = typeof findBy === "string" ? findBy : undefined; - const pause = !findBy || !!findByAsIdString; - const currentModelManager = useModelManager(currentModel); - - const [{ data: currentRecord, fetching: fetchingCurrentRecord, error: fetchCurrentRecordError }] = useFindExistingRecord( - currentModelManager, - findBy ?? {}, - { pause, select: { id: true } } - ); - - if (findByAsIdString) { - return { - currentRecordId: findByAsIdString, - fetchingCurrentRecord: false, - }; - } - - return { - currentRecordId: currentRecord?.id, - fetchingCurrentRecord, - }; -}; - -export const useAllRelatedModelRecords = (props: { - optionLabel?: OptionLabel; - relatedModel: { apiIdentifier: string; namespace?: string[] | string | null }; -}) => { - const { optionLabel, relatedModel } = props; - const optionLabelIsFieldName = typeof optionLabel === "string"; - - const relatedModelManager = useModelManager(relatedModel); - - const [loadedRecords, setLoadedRecords] = useState([]); - const [paginationPage, setPaginationPage] = useState(undefined); - const [searchValue, setSearchValue] = useState(); - - const [{ data: newlyFetchedRecords, fetching, error }, _refetch] = useFindMany(relatedModelManager as any, { - first: optionRecordsToLoadCount, - ...(paginationPage && { after: paginationPage }), - ...(searchValue && { search: searchValue }), - ...(optionLabelIsFieldName && { select: { id: true, [optionLabel]: true } }), - }); - - const hasNextPage = !!newlyFetchedRecords?.hasNextPage; - - const clearPagination = useCallback(() => setPaginationPage(undefined), []); - - const loadNextPage = useCallback(() => { - const canFetchNextPage = - newlyFetchedRecords && newlyFetchedRecords.length >= optionRecordsToLoadCount && hasNextPage && newlyFetchedRecords.endCursor; - - if (canFetchNextPage) { - setPaginationPage(newlyFetchedRecords.endCursor); - } - }, [newlyFetchedRecords]); - - const setSearch = useCallback((search?: string) => { - clearPagination(); - const emptySearch = search === ""; - setSearchValue(emptySearch ? undefined : search); - }, []); - - /** - * This useEffect appends the newly fetched records to the list of records that have already been loaded - * `numberOfRecordsToLoad` are retrieved per `useFindMany` call - */ - useEffect(() => { - if (fetching || !newlyFetchedRecords) { - return; - } - - const allOptions = [ - ...loadedRecords, // Maintain existing options - ...newlyFetchedRecords.map((record) => record), - ]; - - const updatedUniqueOptions = uniqByProperty(allOptions, "id"); - const sortedUniqueOptions = sortByProperty(updatedUniqueOptions, "id"); - - setLoadedRecords(sortedUniqueOptions); - }, [paginationPage, searchValue, fetching]); - - return { - relatedModel: { - records: loadedRecords, - error, - fetching, - }, - - pagination: { - clearPagination, - loadNextPage, - hasNextPage, - }, - - search: { - value: searchValue, - set: setSearch, - }, - }; -}; diff --git a/packages/react/src/auto/mui/inputs/relationships/MUIAutoBelongsToInput.tsx b/packages/react/src/auto/mui/inputs/relationships/MUIAutoBelongsToInput.tsx index b039e9cde..7e9f1078a 100644 --- a/packages/react/src/auto/mui/inputs/relationships/MUIAutoBelongsToInput.tsx +++ b/packages/react/src/auto/mui/inputs/relationships/MUIAutoBelongsToInput.tsx @@ -9,18 +9,16 @@ export const MUIAutoBelongsToInput = autoInput((props: AutoRelationshipInputProp fieldMetadata: { path, metadata }, relatedModelOptions: { options, pagination, search }, - selectedRecordId, + selectedRecord, onSelectRecord, } = useBelongsToInputController(props); - const selectedRecord = options.find((option) => option.id === selectedRecordId); - return ( { const isShowMoreButton = option.recordId === "-1"; - const isSelected = selectedRecordId === option.recordId; + const isSelected = selectedRecord?.id === option.id; return !isShowMoreButton ? ( {isSelected && `✔️ `} @@ -42,7 +40,7 @@ export const MUIAutoBelongsToInput = autoInput((props: AutoRelationshipInputProp ) : null; // No more records to load }} options={[...options, showMoreHoverOption]} - onChange={(e, selectedValue) => onSelectRecord(selectedValue.id)} + onChange={(e, selectedValue) => onSelectRecord(selectedValue)} onClose={() => search.set()} renderInput={(params) => ( { @@ -9,11 +9,13 @@ export const MUIAutoHasManyInput = autoInput((props: AutoRelationshipInputProps) fieldMetadata: { path, metadata }, relatedModelOptions: { options, search, pagination }, - selectedRecordIds, + selectedRecords, onSelectRecord, } = useHasManyInputController(props); + const selectedRecordIds = selectedRecords.map((record) => record.id); + return ( { - const { field } = props; const { fieldMetadata: { path, metadata }, - relatedModelOptions: { options, selected, search, pagination, relatedModel }, - selectedRecordIds, - errorMessage, - isLoading, - + relatedModelOptions: { options, search, pagination }, + selectedRecord, onSelectRecord, - onRemoveRecord, } = useHasOneInputController(props); - const hasMultipleRelatedRecords = selected.records && selected.records.length > 1; - - if (showErrorBannerWhenTooManyRelatedRecords && hasMultipleRelatedRecords) { - return {`Multiple related records for hasOne field "${field}"`}; - } - return ( { const isShowMoreButton = option.recordId === "-1"; - const isSelected = selectedRecordIds === option.recordId; + const isSelected = selectedRecord?.id === option.recordId; return !isShowMoreButton ? ( {isSelected && `✔️ `} diff --git a/packages/react/src/auto/polaris/inputs/relationships/PolarisAutoBelongsToInput.tsx b/packages/react/src/auto/polaris/inputs/relationships/PolarisAutoBelongsToInput.tsx index 8edbafd00..63b4e468b 100644 --- a/packages/react/src/auto/polaris/inputs/relationships/PolarisAutoBelongsToInput.tsx +++ b/packages/react/src/auto/polaris/inputs/relationships/PolarisAutoBelongsToInput.tsx @@ -2,43 +2,38 @@ import { Combobox, Tag } from "@shopify/polaris"; import React from "react"; import { autoInput } from "../../../AutoInput.js"; import { useBelongsToInputController } from "../../../hooks/useBelongsToInputController.js"; -import { optionRecordsToLoadCount } from "../../../hooks/useRelatedModelOptions.js"; +import { getRecordAsOption, optionRecordsToLoadCount, useOptionLabelForField } from "../../../hooks/useRelatedModel.js"; import type { AutoRelationshipInputProps } from "../../../interfaces/AutoRelationshipInputProps.js"; import { RelatedModelOptions } from "./RelatedModelOptions.js"; export const PolarisAutoBelongsToInput = autoInput((props: AutoRelationshipInputProps) => { const { fieldMetadata: { path, metadata }, - relatedModelOptions: { options, searchFilterOptions, pagination, search }, - + relatedModelOptions: { options, searchFilterOptions, pagination, search, relatedModel }, isLoading, errorMessage, - - selectedRecordId, - selectedRelatedModelRecordMissing, - + selectedRecord, onSelectRecord, onRemoveRecord, } = useBelongsToInputController(props); - const selectedRecordLabel = selectedRecordId - ? options.find((option) => option.id === selectedRecordId)?.label ?? `id: ${selectedRecordId}` - : null; + const optionLabel = useOptionLabelForField(props.field, props.optionLabel); + + const selectedOption = selectedRecord ? getRecordAsOption(selectedRecord, optionLabel) : null; - const selectedRecordTag = selectedRecordId ? ( - -

- {selectedRecordLabel} -

+ const selectedRecordTag = selectedOption ? ( + +

{selectedOption.label}

) : null; - const onSelect = (recordId: string) => { - const idIsAlreadySelected = selectedRecordId === recordId; + const onSelect = (record: Record) => { + const recordId = record.id; + const idIsAlreadySelected = selectedRecord?.id === recordId; idIsAlreadySelected ? onRemoveRecord() // clear selection - : onSelectRecord(recordId); // make single selection + : onSelectRecord(record); // make single selection }; return ( @@ -62,9 +57,10 @@ export const PolarisAutoBelongsToInput = autoInput((props: AutoRelationshipInput id === selectedRecordId} + checkSelected={(id) => id === selectedRecord?.id} onSelect={onSelect} options={searchFilterOptions} + records={relatedModel.records} /> diff --git a/packages/react/src/auto/polaris/inputs/relationships/PolarisAutoHasManyInput.tsx b/packages/react/src/auto/polaris/inputs/relationships/PolarisAutoHasManyInput.tsx index cdb6fd4af..bf11fe0c6 100644 --- a/packages/react/src/auto/polaris/inputs/relationships/PolarisAutoHasManyInput.tsx +++ b/packages/react/src/auto/polaris/inputs/relationships/PolarisAutoHasManyInput.tsx @@ -1,9 +1,8 @@ -import type { GadgetRecordList } from "@gadgetinc/api-client-core"; -import { Banner, Combobox } from "@shopify/polaris"; -import React from "react"; +import { Combobox } from "@shopify/polaris"; +import React, { useMemo } from "react"; import { autoInput } from "../../../AutoInput.js"; -import { useHasManyInputController } from "../../../hooks/useHasManyInputController.js"; -import { optionRecordsToLoadCount } from "../../../hooks/useRelatedModelOptions.js"; +import { useHasManyInputController } from "../../../hooks/useHasManyController.js"; +import { optionRecordsToLoadCount, useOptionLabelForField } from "../../../hooks/useRelatedModel.js"; import type { AutoRelationshipInputProps } from "../../../interfaces/AutoRelationshipInputProps.js"; import { RelatedModelOptions } from "./RelatedModelOptions.js"; import { getSelectedRelatedRecordTags } from "./SelectedRelatedRecordTags.js"; @@ -13,9 +12,9 @@ export const PolarisAutoHasManyInput = autoInput((props: AutoRelationshipInputPr const { fieldMetadata: { path, metadata }, - relatedModelOptions: { options, searchFilterOptions, selected, search, pagination }, + relatedModelOptions: { options, searchFilterOptions, search, pagination, relatedModel }, - selectedRecordIds, + selectedRecords, errorMessage, isLoading, @@ -23,9 +22,15 @@ export const PolarisAutoHasManyInput = autoInput((props: AutoRelationshipInputPr onRemoveRecord, } = useHasManyInputController(props); - if ((selected.records as GadgetRecordList)?.hasNextPage) { - return {`Too many related records for ${field}. Cannot edit`}; - } + const optionLabel = useOptionLabelForField(field, props.optionLabel); + + // if ((selected.records as GadgetRecordList)?.hasNextPage) { + // return {`Too many related records for ${field}. Cannot edit`}; + // } + + const selectedRecordIds = useMemo(() => { + return selectedRecords.map((record) => record.id).filter((id) => !!id) as string[]; + }, [selectedRecords]); return ( <> @@ -39,9 +44,9 @@ export const PolarisAutoHasManyInput = autoInput((props: AutoRelationshipInputPr placeholder="Search" autoComplete="off" verticalContent={getSelectedRelatedRecordTags({ - selectedRecordIds, + selectedRecords, onRemoveRecord, - options, + optionLabel, })} /> } @@ -51,6 +56,7 @@ export const PolarisAutoHasManyInput = autoInput((props: AutoRelationshipInputPr > selectedRecordIds.includes(id)} errorMessage={errorMessage} diff --git a/packages/react/src/auto/polaris/inputs/relationships/PolarisAutoHasOneInput.tsx b/packages/react/src/auto/polaris/inputs/relationships/PolarisAutoHasOneInput.tsx index 5bbb99951..e1b9dad63 100644 --- a/packages/react/src/auto/polaris/inputs/relationships/PolarisAutoHasOneInput.tsx +++ b/packages/react/src/auto/polaris/inputs/relationships/PolarisAutoHasOneInput.tsx @@ -1,36 +1,32 @@ -import { Banner, Combobox } from "@shopify/polaris"; +import { Combobox, Tag } from "@shopify/polaris"; import React from "react"; import { autoInput } from "../../../AutoInput.js"; import { useHasOneInputController } from "../../../hooks/useHasOneInputController.js"; -import { optionRecordsToLoadCount } from "../../../hooks/useRelatedModelOptions.js"; +import { getRecordAsOption, optionRecordsToLoadCount, useOptionLabelForField } from "../../../hooks/useRelatedModel.js"; import type { AutoRelationshipInputProps } from "../../../interfaces/AutoRelationshipInputProps.js"; import { RelatedModelOptions } from "./RelatedModelOptions.js"; -import { getSelectedRelatedRecordTags } from "./SelectedRelatedRecordTags.js"; - -/** - * TODO - Enable when API level 1-1 relationship mappings are maintained by calling updates on other records - */ -const showErrorBannerWhenTooManyRelatedRecords = false; export const PolarisAutoHasOneInput = autoInput((props: AutoRelationshipInputProps) => { const { field } = props; const { fieldMetadata: { path, metadata }, - relatedModelOptions: { options, searchFilterOptions, selected, search, pagination }, - - selectedRecordIds, + relatedModelOptions: { options, searchFilterOptions, search, pagination, relatedModel }, + selectedRecord, errorMessage, isLoading, - onSelectRecord, onRemoveRecord, } = useHasOneInputController(props); - const hasMultipleRelatedRecords = selected.records && selected.records.length > 1; + const optionLabel = useOptionLabelForField(field, props.optionLabel); + + const selectedOption = selectedRecord ? getRecordAsOption(selectedRecord, optionLabel) : null; - if (showErrorBannerWhenTooManyRelatedRecords && hasMultipleRelatedRecords) { - return {`Multiple related records for hasOne field "${field}"`}; - } + const selectedRecordTag = selectedOption ? ( + selectedRecord && onRemoveRecord(selectedRecord)} key={`selectedRecordTag_${selectedOption.id}`}> +

{selectedOption.label}

+
+ ) : null; return ( <> @@ -43,11 +39,7 @@ export const PolarisAutoHasOneInput = autoInput((props: AutoRelationshipInputPro name={path} placeholder="Search" autoComplete="off" - verticalContent={getSelectedRelatedRecordTags({ - selectedRecordIds, - onRemoveRecord, - options, - })} + verticalContent={selectedRecordTag} /> } onScrolledToBottom={pagination.loadNextPage} @@ -56,8 +48,9 @@ export const PolarisAutoHasOneInput = autoInput((props: AutoRelationshipInputPro selectedRecordIds.includes(id)} + checkSelected={(id) => selectedRecord?.id === id} onSelect={onSelectRecord} + records={relatedModel.records} options={searchFilterOptions} /> diff --git a/packages/react/src/auto/polaris/inputs/relationships/RelatedModelOptions.tsx b/packages/react/src/auto/polaris/inputs/relationships/RelatedModelOptions.tsx index 0f02dae0a..7521e6f05 100644 --- a/packages/react/src/auto/polaris/inputs/relationships/RelatedModelOptions.tsx +++ b/packages/react/src/auto/polaris/inputs/relationships/RelatedModelOptions.tsx @@ -4,16 +4,25 @@ import { ListMessage, NoRecordsMessage, SelectableOption, getErrorMessage } from export const RelatedModelOptions = (props: { options: { id: string; label: string }[]; + records?: Record[]; isLoading?: boolean; errorMessage?: string; checkSelected?: (id: string) => boolean; - onSelect: (recordId: string) => void; + onSelect: (record: Record) => void; }) => { - const { checkSelected, onSelect, isLoading, errorMessage, options } = props; + const { checkSelected, onSelect, isLoading, errorMessage, options, records } = props; return ( - + { + const record = records?.find((record) => record.id === id) ?? { id }; + + const { createdAt: _createdAt, updatedAt: _updatedAt, ...recordWithoutTimestamps } = record; + + onSelect(recordWithoutTimestamps); + }} + > {options.length ? ( options.map((option) => ) ) : errorMessage ? ( diff --git a/packages/react/src/auto/polaris/inputs/relationships/SelectedRelatedRecordTags.tsx b/packages/react/src/auto/polaris/inputs/relationships/SelectedRelatedRecordTags.tsx index 7f2bc028b..0f414cb53 100644 --- a/packages/react/src/auto/polaris/inputs/relationships/SelectedRelatedRecordTags.tsx +++ b/packages/react/src/auto/polaris/inputs/relationships/SelectedRelatedRecordTags.tsx @@ -1,12 +1,14 @@ import { Tag } from "@shopify/polaris"; import React from "react"; +import { getRecordsAsOptions } from "../../../hooks/useRelatedModel.js"; +import type { OptionLabel } from "../../../interfaces/AutoRelationshipInputProps.js"; export const getSelectedRelatedRecordTags = (props: { - selectedRecordIds: string[]; - options: { id: string; label: string }[]; - onRemoveRecord: (id: string) => void; + selectedRecords: Record[]; + optionLabel: OptionLabel; + onRemoveRecord: (record: Record) => void; }) => { - if (!props.selectedRecordIds.length) { + if (!props.selectedRecords.length) { // A separate component getter is used here to return null instead of <>null which adds extra height to the text field return null; } @@ -14,18 +16,25 @@ export const getSelectedRelatedRecordTags = (props: { }; export const SelectedRelatedRecordTags = (props: { - selectedRecordIds: string[]; - options: { id: string; label: string }[]; - onRemoveRecord: (id: string) => void; + selectedRecords: Record[]; + optionLabel: OptionLabel; + onRemoveRecord: (record: Record) => void; }) => { - const { selectedRecordIds, options, onRemoveRecord } = props; + const { selectedRecords, optionLabel, onRemoveRecord } = props; - return selectedRecordIds.length - ? selectedRecordIds.map((id) => { - const option = options.find((option) => option.id === id); + const options = getRecordsAsOptions(selectedRecords, optionLabel); + + return options.length + ? options.map((option) => { return ( - onRemoveRecord(id)}> - {option ? option.label : `id: ${id}`} + { + const record = selectedRecords.find((record) => record.id === option.id); + onRemoveRecord(record ?? { id: option.id }); + }} + > + {option.label} ); }) diff --git a/packages/react/src/metadata.tsx b/packages/react/src/metadata.tsx index 9d7b628ce..125d57a73 100644 --- a/packages/react/src/metadata.tsx +++ b/packages/react/src/metadata.tsx @@ -462,15 +462,15 @@ export const useActionMetadata = ( /** * @internal */ -export const filterAutoFormFieldList = ( +export const buildAutoFormFieldList = ( fields: FieldMetadata[] | undefined, options?: { include?: string[]; exclude?: string[]; isUpsertAction?: boolean } -): FieldMetadata[] => { +): [string, FieldMetadata][] => { if (!fields) { return []; } - let subset = fields; + let subset: [string, FieldMetadata][] = fields.map((field) => [field.apiIdentifier, field]); if (options?.include && options?.exclude) { throw new Error("Cannot use both 'include' and 'exclude' options at the same time"); @@ -481,28 +481,57 @@ export const filterAutoFormFieldList = ( subset = []; const includes = new Set(options.include); - for (const includedFieldApiId of Array.from(includes)) { - const metadataField = fields.find((field) => field.apiIdentifier === includedFieldApiId); + const includeGroups: Record = {}; + + for (const path of Array.from(includes)) { + const [rootSegment, ...childSegments] = path.split("."); + + includeGroups[rootSegment] ??= []; + + if (childSegments.length) { + includeGroups[rootSegment].push(childSegments.join(".")); + } + } + + for (const [rootApiIdentifier, childIdentifiers] of Object.entries(includeGroups)) { + const metadataField = fields.find((field) => field.apiIdentifier === rootApiIdentifier); if (metadataField) { - subset.push(metadataField); + subset.push([rootApiIdentifier, metadataField]); + + if ( + childIdentifiers.length > 0 && + "relatedModel" in metadataField.configuration && + metadataField.configuration.relatedModel?.fields + ) { + const childFields = buildAutoFormFieldList(metadataField.configuration.relatedModel.fields, { + include: childIdentifiers, + }); + subset.push( + ...childFields.map( + ([childApiIdentifier, childField]) => [`${rootApiIdentifier}.${childApiIdentifier}`, childField] as [string, FieldMetadata] + ) + ); + } } } } if (options?.exclude) { const excludes = new Set(options.exclude); - subset = subset.filter((field) => !excludes.has(field.apiIdentifier)); + subset = subset.filter(([_, field]) => !excludes.has(field.apiIdentifier)); } // Remove `hasMany` fields that emerge from `hasManyThrough` fields that are not actually model fields - subset = subset.filter((field) => !isJoinModelHasManyField(field)); + subset = subset.filter(([_, field]) => !isJoinModelHasManyField(field)); // Filter out fields that are not supported by the form - const validFieldTypeSubset = subset.filter(options?.isUpsertAction ? isAcceptedUpsertFieldType : isAcceptedFieldType); + const validFieldTypeSubset = subset.filter(([_, field]) => + options?.isUpsertAction ? isAcceptedUpsertFieldType(field) : isAcceptedFieldType(field) + ); return options?.include ? validFieldTypeSubset // Everything explicitly included is valid - : validFieldTypeSubset.filter(isNotRelatedToSpecialModelFilter); // Without explicit includes, filter out relationships to special models + : validFieldTypeSubset.filter(([_, field]) => isNotRelatedToSpecialModelFilter(field)); // Without explicit includes, filter out relationships to special models }; /** diff --git a/packages/react/src/use-action-form/utils.ts b/packages/react/src/use-action-form/utils.ts index a04d6a285..3ba424bfe 100644 --- a/packages/react/src/use-action-form/utils.ts +++ b/packages/react/src/use-action-form/utils.ts @@ -409,12 +409,11 @@ const getParentRelationshipFieldGraphqlApiInput = (props: { input: any; result: const { input, result } = props; const { __typename, ...rest } = result; - if ("__id" in rest) { - if ("__unlinkedInverseField" in rest && rest.__unlinkedInverseField) { - const inverseFieldApiId = rest.__unlinkedInverseField; - return { update: { id: rest.__id, [inverseFieldApiId]: { _link: null } } }; - } - return { update: { id: rest.__id } }; // Calling this update action automatically links it to the current parent model's record ID + if ("_link" in rest && rest._link) { + return { update: { id: rest._link } }; + } else if ("_unlink" in rest && rest._unlink) { + const { id, inverseFieldApiIdentifier } = rest._unlink; + return { update: { id, [inverseFieldApiIdentifier]: { _link: null } } }; } else { const inputHasId = "id" in input; return inputHasId ? { update: { ...rest } } : { create: { ...rest } }; diff --git a/packages/react/src/use-table/helpers.tsx b/packages/react/src/use-table/helpers.tsx index 91e9db3bd..987fba49e 100644 --- a/packages/react/src/use-table/helpers.tsx +++ b/packages/react/src/use-table/helpers.tsx @@ -297,6 +297,10 @@ const isHasManyOrHasManyThroughField = (field: { fieldType: GadgetFieldType }) = return field.fieldType === GadgetFieldType.HasMany || field.fieldType === GadgetFieldType.HasManyThrough; }; +const isRelationshipField = (field: { fieldType: GadgetFieldType }) => { + return isHasOneOrBelongsToField(field) || isHasManyOrHasManyThroughField(field); +}; + const richTextSelection = { markdown: true, truncatedHTML: true, @@ -400,3 +404,55 @@ const getFieldSelectionErrorMessage = (columnPath: string) => { RELATED_HAS_ONE_OR_BELONGS_TO_FIELD_NOT_EXIST: `Field '${columnPath}' does not exist in the related model`, } as const; }; + +export const pathListToSelection = (pathList: string[], fieldMetadataArray: FieldMetadata[]) => { + const selection: FieldSelection = { + id: true, + }; + + const pathGroups: Record = {}; + + for (const path of pathList) { + const [rootSegment, ...childSegments] = path.split("."); + + pathGroups[rootSegment] ??= []; + + if (childSegments.length) { + pathGroups[rootSegment].push(childSegments.join(".")); + } + } + + for (const [rootPath, childPaths] of Object.entries(pathGroups)) { + const field = fieldMetadataArray.find((metadata) => metadata.apiIdentifier == rootPath); + + if (!field) { + throw new Error(`No metadata found for ${rootPath}`); + } + + if (isRelationshipField(field)) { + const configuration = field.configuration; + if (configuration && "relatedModel" in configuration && configuration.relatedModel) { + const relatedModel = configuration.relatedModel; + const relatedSelection = { + id: true, + [relatedModel?.defaultDisplayField.apiIdentifier]: true, + ...(childPaths.length && relatedModel.fields ? pathListToSelection(childPaths, relatedModel.fields) : {}), + }; + + if (isHasManyOrHasManyThroughField(field)) { + selection[field.apiIdentifier] = { + edges: { + node: relatedSelection, + }, + }; + } else { + selection[field.apiIdentifier] = relatedSelection; + } + } + } else { + selection[field.apiIdentifier] = getNonRelationshipSelectionValue(field); + } + } + + return selection; +}; diff --git a/packages/react/src/useActionForm.ts b/packages/react/src/useActionForm.ts index b3910724c..75ff67948 100644 --- a/packages/react/src/useActionForm.ts +++ b/packages/react/src/useActionForm.ts @@ -78,6 +78,10 @@ type ActionFormOptions< * If false, don't throw an error if the the given findBy value is an invalid object */ throwOnInvalidFindByObject?: boolean; + /** + * Whether to pause fetching the record when using findBy. + */ + pause?: boolean; } : // eslint-disable-next-line @typescript-eslint/ban-types {}); @@ -107,6 +111,7 @@ export const useActionForm = < ): UseActionFormResult => { const findById = options && "findBy" in options ? options.findBy : undefined; const throwOnInvalidFindByObject = options && "findBy" in options ? options?.throwOnInvalidFindByObject ?? true : true; + const pause = options && "pause" in options ? options.pause : undefined; const api = useApi(); const findExistingRecord = !!findById; const hasSetInitialValues = useRef(!findExistingRecord); @@ -116,7 +121,7 @@ export const useActionForm = < // find the existing record if there is one const modelManager = isModelAction ? getModelManager(api, action.modelApiIdentifier, action.namespace) : undefined; const [findResult] = useFindExistingRecord(modelManager, findById || "1", { - pause: !findExistingRecord, + pause: pause || !findExistingRecord, select: actionSelect, throwOnInvalidFindByObject, });