diff --git a/packages/api-client-core/spec/operationBuilders.spec.ts b/packages/api-client-core/spec/operationBuilders.spec.ts index a822cafbd..523d5f34d 100644 --- a/packages/api-client-core/spec/operationBuilders.spec.ts +++ b/packages/api-client-core/spec/operationBuilders.spec.ts @@ -1621,4 +1621,98 @@ describe("operation builders", () => { `); }); }); + + describe("selection auto-expansion", () => { + const defaultSelectionWithSpecialFields = { + __typename: true, + id: true, + name: true, + richText: { markdown: true, truncatedHTML: true }, + fileField: { url: true, mimeType: true, fileName: true }, + roleField: { key: true, name: true }, + }; + + describe("findOneOperation", () => { + test("auto-expands richText: true to sub-selection from defaultSelection", () => { + const result = findOneOperation("widget", "123", defaultSelectionWithSpecialFields, "widget", { + select: { id: true, richText: true }, + }); + expect(result.query).toContain("richText"); + expect(result.query).toContain("markdown"); + expect(result.query).toContain("truncatedHTML"); + }); + + test("auto-expands fileField: true to sub-selection from defaultSelection", () => { + const result = findOneOperation("widget", "123", defaultSelectionWithSpecialFields, "widget", { + select: { id: true, fileField: true }, + }); + expect(result.query).toContain("fileField"); + expect(result.query).toContain("url"); + expect(result.query).toContain("mimeType"); + expect(result.query).toContain("fileName"); + }); + + test("auto-expands roleField: true to sub-selection from defaultSelection", () => { + const result = findOneOperation("widget", "123", defaultSelectionWithSpecialFields, "widget", { + select: { id: true, roleField: true }, + }); + expect(result.query).toContain("roleField"); + expect(result.query).toContain("key"); + expect(result.query).toContain("name"); + }); + + test("preserves explicit object selections without overwriting", () => { + const result = findOneOperation("widget", "123", defaultSelectionWithSpecialFields, "widget", { + select: { id: true, richText: { markdown: true } }, + }); + expect(result.query).toContain("markdown"); + expect(result.query).not.toContain("truncatedHTML"); + }); + + test("leaves normal scalar fields untouched", () => { + const result = findOneOperation("widget", "123", defaultSelectionWithSpecialFields, "widget", { select: { id: true, name: true } }); + expect(result.query).toContain("id"); + expect(result.query).toContain("name"); + expect(result.query).not.toContain("richText"); + }); + }); + + describe("findManyOperation", () => { + test("auto-expands richText: true in findMany", () => { + const result = findManyOperation("widgets", defaultSelectionWithSpecialFields, "widget", { + select: { id: true, richText: true }, + }); + expect(result.query).toContain("richText"); + expect(result.query).toContain("markdown"); + expect(result.query).toContain("truncatedHTML"); + }); + + test("auto-expands fileField: true in findMany", () => { + const result = findManyOperation("widgets", defaultSelectionWithSpecialFields, "widget", { + select: { id: true, fileField: true }, + }); + expect(result.query).toContain("fileField"); + expect(result.query).toContain("url"); + expect(result.query).toContain("mimeType"); + }); + }); + + describe("actionOperation", () => { + test("auto-expands richText: true in action", () => { + const result = actionOperation( + "createWidget", + defaultSelectionWithSpecialFields, + "widget", + "widget", + { + widget: { type: "CreateWidgetInput", value: { name: "test" } }, + }, + { select: { id: true, richText: true } } + ); + expect(result.query).toContain("richText"); + expect(result.query).toContain("markdown"); + expect(result.query).toContain("truncatedHTML"); + }); + }); + }); }); diff --git a/packages/api-client-core/src/operationBuilders.ts b/packages/api-client-core/src/operationBuilders.ts index f193615da..3dbbb4681 100644 --- a/packages/api-client-core/src/operationBuilders.ts +++ b/packages/api-client-core/src/operationBuilders.ts @@ -24,6 +24,30 @@ const fieldSelectionToQueryCompilerFields = (selection: FieldSelection, includeT export type FindFirstPaginationOptions = Omit; +/** + * When a user passes `{ field: true }` for a field that requires sub-selections + * (like richText, file, or role fields), auto-expand it using the defaultSelection. + */ +const normalizeSelection = (selection: FieldSelection, defaultSelection: FieldSelection): FieldSelection => { + const result: FieldSelection = {}; + for (const [key, value] of Object.entries(selection)) { + const defaultValue = defaultSelection[key]; + if (value === true && defaultValue && typeof defaultValue === "object") { + result[key] = defaultValue; + } else if (value && typeof value === "object" && defaultValue && typeof defaultValue === "object") { + result[key] = normalizeSelection(value as FieldSelection, defaultValue as FieldSelection); + } else { + result[key] = value; + } + } + return result; +}; + +const resolveSelection = (defaultSelection: FieldSelection, userSelection?: FieldSelection | null): FieldSelection => { + if (!userSelection) return defaultSelection; + return normalizeSelection(userSelection, defaultSelection); +}; + const directivesForOptions = (options?: BaseFindOptions | null) => { if (options?.live) return ["@live"]; return undefined; @@ -41,7 +65,7 @@ export const findOneOperation = ( if (typeof id !== "undefined") variables.id = Var({ type: "GadgetID!", value: id }); let fields = { - [operation]: Call(variables, fieldSelectionToQueryCompilerFields(options?.select || defaultSelection, true)), + [operation]: Call(variables, fieldSelectionToQueryCompilerFields(resolveSelection(defaultSelection, options?.select), true)), }; fields = namespacify(namespace, fields); @@ -105,7 +129,7 @@ export const findManyOperation = ( pageInfo: { hasNextPage: true, hasPreviousPage: true, startCursor: true, endCursor: true }, edges: { cursor: true, - node: fieldSelectionToQueryCompilerFields(options?.select || defaultSelection, true), + node: fieldSelectionToQueryCompilerFields(resolveSelection(defaultSelection, options?.select), true), }, } ), @@ -176,7 +200,7 @@ export const actionOperation = ( isBulkAction?: boolean | null, hasReturnType?: HasReturnType | null ) => { - const selection = options?.select || defaultSelection; + const selection = resolveSelection(defaultSelection!, options?.select); let fields: BuilderFieldSelection = { [operation]: Call( @@ -223,7 +247,7 @@ export const backgroundActionResultOperation = { }); }); }); + + describe("exported selection constants", () => { + it("richTextSelection should contain markdown and truncatedHTML", () => { + expect(richTextSelection).toEqual({ + markdown: true, + truncatedHTML: true, + }); + }); + + it("fileSelection should contain url, mimeType, and fileName", () => { + expect(fileSelection).toEqual({ + url: true, + mimeType: true, + fileName: true, + }); + }); + + it("roleAssignmentsSelection should contain key and name", () => { + expect(roleAssignmentsSelection).toEqual({ + key: true, + name: true, + }); + }); + + it("getTableSelectionMap uses the exported selection constants for special field types", () => { + const result = getTableSelectionMap({ + targetColumns: ["description", "image", "roles"], + fieldMetadataTree: fieldMetadataArrayToFieldMetadataTree([ + getSimpleFieldMetadata("Description", "description", GadgetFieldType.RichText), + getSimpleFieldMetadata("Image", "image", GadgetFieldType.File), + getSimpleFieldMetadata("Roles", "roles", GadgetFieldType.RoleAssignments), + ]), + defaultSelection: {}, + }); + + expect(result).toEqual({ + id: true, + description: richTextSelection, + image: fileSelection, + roles: roleAssignmentsSelection, + }); + }); + }); }); const gadgetGenericFieldConfig = { diff --git a/packages/react/src/auto/AutoForm.ts b/packages/react/src/auto/AutoForm.ts index 24aae26f1..1c3b332cb 100644 --- a/packages/react/src/auto/AutoForm.ts +++ b/packages/react/src/auto/AutoForm.ts @@ -22,7 +22,15 @@ import type { UseActionFormSubmit, } from "../use-action-form/types.js"; import { isPlainObject, processDefaultValues, toDefaultValues } from "../use-action-form/utils.js"; -import { getRelatedModelFields, isHasManyOrHasManyThroughField, isRelationshipField, pathListToSelection } from "../use-table/helpers.js"; +import { + fileSelection, + getRelatedModelFields, + isHasManyOrHasManyThroughField, + isRelationshipField, + pathListToSelection, + richTextSelection, + roleAssignmentsSelection, +} from "../use-table/helpers.js"; import type { FieldErrors, FieldValues, UseFormReturn } from "../useActionForm.js"; import { useActionForm } from "../useActionForm.js"; import { get, getFlattenedObjectKeys, set } from "../utils.js"; @@ -221,7 +229,7 @@ const useFormSelection = (props: { if (!select || !modelApiIdentifier) { return; } - return forceIdsIntoSelect({ select, rootFieldsMetadata }); + return forceRequiredFieldsIntoSelect({ select, rootFieldsMetadata }); }, [select, modelApiIdentifier, rootFieldsMetadata]); if (!modelApiIdentifier || !fields.length) { @@ -238,38 +246,56 @@ const useFormSelection = (props: { return pathListToSelection(modelApiIdentifier, paths, fieldMetaData); }; -const forceIdsIntoSelect = (props: { select: FieldSelection; rootFieldsMetadata: FieldMetadata[] }) => { +const forceRequiredFieldsIntoSelect = (props: { select: FieldSelection; rootFieldsMetadata: FieldMetadata[] }) => { const { select: originalSelect, rootFieldsMetadata } = props; const select = structuredClone(originalSelect); select.id = true; // Always select the ID for the root model - const addIdToSelection = (selectPath: string, fieldMetadata: FieldMetadata) => { - if (!isRelationshipField(fieldMetadata)) { - return; // Non relationships do not need additional selection - } + const addRequiredFieldsToSelection = (selectPath: string, fieldMetadata: FieldMetadata) => { + const isRichTextField = fieldMetadata.fieldType === FieldType.RichText; + const isFileField = fieldMetadata.fieldType === FieldType.File; + const isRolesField = fieldMetadata.fieldType === FieldType.RoleAssignments; + const isRelationship = isRelationshipField(fieldMetadata); const existingSelection = get(select, selectPath); - if (!existingSelection || typeof existingSelection !== "object") { - // Do not go deeper than what is defined in the select object - return; + if (!existingSelection) { + return; // Do not select at all } - const isManyRelation = isHasManyOrHasManyThroughField(fieldMetadata); - const currentFieldSelectPathPrefix = isManyRelation ? `${selectPath}.edges.node` : `${selectPath}`; - const idPath = `${currentFieldSelectPathPrefix}.id`; + if (isRichTextField) { + return set(select, selectPath, richTextSelection); // Assume that the whole rich text is expected to be selected + } - set(select, idPath, true); + if (isFileField) { + return set(select, selectPath, fileSelection); // Assume whole file is expected to be selected + } + if (isRolesField) { + return set(select, selectPath, roleAssignmentsSelection); // Assume whole role assignments are expected to be selected + } - const relatedModelFields = getRelatedModelFields(fieldMetadata); + if (isRelationship) { + if (typeof existingSelection !== "object") { + // Do not go deeper than what is defined in the select object + return; + } + + const isManyRelation = isHasManyOrHasManyThroughField(fieldMetadata); + const currentFieldSelectPathPrefix = isManyRelation ? `${selectPath}.edges.node` : `${selectPath}`; + const idPath = `${currentFieldSelectPathPrefix}.id`; + + set(select, idPath, true); - for (const relatedModelField of relatedModelFields) { - addIdToSelection(`${currentFieldSelectPathPrefix}.${relatedModelField.apiIdentifier}`, relatedModelField); + const relatedModelFields = getRelatedModelFields(fieldMetadata); + + for (const relatedModelField of relatedModelFields) { + addRequiredFieldsToSelection(`${currentFieldSelectPathPrefix}.${relatedModelField.apiIdentifier}`, relatedModelField); + } } }; for (const field of rootFieldsMetadata) { - addIdToSelection(field.apiIdentifier, field); + addRequiredFieldsToSelection(field.apiIdentifier, field); } return select; diff --git a/packages/react/src/use-table/helpers.tsx b/packages/react/src/use-table/helpers.tsx index b23ed5802..a78aaedca 100644 --- a/packages/react/src/use-table/helpers.tsx +++ b/packages/react/src/use-table/helpers.tsx @@ -379,18 +379,18 @@ export const isRelationshipField = (field: { fieldType: GadgetFieldType }) => { return isHasOneOrBelongsToField(field) || isHasManyOrHasManyThroughField(field); }; -const richTextSelection = { +export const richTextSelection = { markdown: true, truncatedHTML: true, }; -const fileSelection = { +export const fileSelection = { url: true, mimeType: true, fileName: true, }; -const roleAssignmentsSelection = { +export const roleAssignmentsSelection = { key: true, name: true, };