Skip to content

Commit 3c1718f

Browse files
Merge pull request #852 from gadget-inc/mill/forceProperSelectionForSpecialFields
Forcefully add special subproperties to the selection of RichText, file, and role fields
2 parents 57ad506 + 7e17098 commit 3c1718f

5 files changed

Lines changed: 215 additions & 25 deletions

File tree

packages/api-client-core/spec/operationBuilders.spec.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1621,4 +1621,98 @@ describe("operation builders", () => {
16211621
`);
16221622
});
16231623
});
1624+
1625+
describe("selection auto-expansion", () => {
1626+
const defaultSelectionWithSpecialFields = {
1627+
__typename: true,
1628+
id: true,
1629+
name: true,
1630+
richText: { markdown: true, truncatedHTML: true },
1631+
fileField: { url: true, mimeType: true, fileName: true },
1632+
roleField: { key: true, name: true },
1633+
};
1634+
1635+
describe("findOneOperation", () => {
1636+
test("auto-expands richText: true to sub-selection from defaultSelection", () => {
1637+
const result = findOneOperation("widget", "123", defaultSelectionWithSpecialFields, "widget", {
1638+
select: { id: true, richText: true },
1639+
});
1640+
expect(result.query).toContain("richText");
1641+
expect(result.query).toContain("markdown");
1642+
expect(result.query).toContain("truncatedHTML");
1643+
});
1644+
1645+
test("auto-expands fileField: true to sub-selection from defaultSelection", () => {
1646+
const result = findOneOperation("widget", "123", defaultSelectionWithSpecialFields, "widget", {
1647+
select: { id: true, fileField: true },
1648+
});
1649+
expect(result.query).toContain("fileField");
1650+
expect(result.query).toContain("url");
1651+
expect(result.query).toContain("mimeType");
1652+
expect(result.query).toContain("fileName");
1653+
});
1654+
1655+
test("auto-expands roleField: true to sub-selection from defaultSelection", () => {
1656+
const result = findOneOperation("widget", "123", defaultSelectionWithSpecialFields, "widget", {
1657+
select: { id: true, roleField: true },
1658+
});
1659+
expect(result.query).toContain("roleField");
1660+
expect(result.query).toContain("key");
1661+
expect(result.query).toContain("name");
1662+
});
1663+
1664+
test("preserves explicit object selections without overwriting", () => {
1665+
const result = findOneOperation("widget", "123", defaultSelectionWithSpecialFields, "widget", {
1666+
select: { id: true, richText: { markdown: true } },
1667+
});
1668+
expect(result.query).toContain("markdown");
1669+
expect(result.query).not.toContain("truncatedHTML");
1670+
});
1671+
1672+
test("leaves normal scalar fields untouched", () => {
1673+
const result = findOneOperation("widget", "123", defaultSelectionWithSpecialFields, "widget", { select: { id: true, name: true } });
1674+
expect(result.query).toContain("id");
1675+
expect(result.query).toContain("name");
1676+
expect(result.query).not.toContain("richText");
1677+
});
1678+
});
1679+
1680+
describe("findManyOperation", () => {
1681+
test("auto-expands richText: true in findMany", () => {
1682+
const result = findManyOperation("widgets", defaultSelectionWithSpecialFields, "widget", {
1683+
select: { id: true, richText: true },
1684+
});
1685+
expect(result.query).toContain("richText");
1686+
expect(result.query).toContain("markdown");
1687+
expect(result.query).toContain("truncatedHTML");
1688+
});
1689+
1690+
test("auto-expands fileField: true in findMany", () => {
1691+
const result = findManyOperation("widgets", defaultSelectionWithSpecialFields, "widget", {
1692+
select: { id: true, fileField: true },
1693+
});
1694+
expect(result.query).toContain("fileField");
1695+
expect(result.query).toContain("url");
1696+
expect(result.query).toContain("mimeType");
1697+
});
1698+
});
1699+
1700+
describe("actionOperation", () => {
1701+
test("auto-expands richText: true in action", () => {
1702+
const result = actionOperation(
1703+
"createWidget",
1704+
defaultSelectionWithSpecialFields,
1705+
"widget",
1706+
"widget",
1707+
{
1708+
widget: { type: "CreateWidgetInput", value: { name: "test" } },
1709+
},
1710+
{ select: { id: true, richText: true } }
1711+
);
1712+
expect(result.query).toContain("richText");
1713+
expect(result.query).toContain("markdown");
1714+
expect(result.query).toContain("truncatedHTML");
1715+
});
1716+
});
1717+
});
16241718
});

packages/api-client-core/src/operationBuilders.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,30 @@ const fieldSelectionToQueryCompilerFields = (selection: FieldSelection, includeT
2424

2525
export type FindFirstPaginationOptions = Omit<FindManyOptions, "first" | "last" | "before" | "after">;
2626

27+
/**
28+
* When a user passes `{ field: true }` for a field that requires sub-selections
29+
* (like richText, file, or role fields), auto-expand it using the defaultSelection.
30+
*/
31+
const normalizeSelection = (selection: FieldSelection, defaultSelection: FieldSelection): FieldSelection => {
32+
const result: FieldSelection = {};
33+
for (const [key, value] of Object.entries(selection)) {
34+
const defaultValue = defaultSelection[key];
35+
if (value === true && defaultValue && typeof defaultValue === "object") {
36+
result[key] = defaultValue;
37+
} else if (value && typeof value === "object" && defaultValue && typeof defaultValue === "object") {
38+
result[key] = normalizeSelection(value as FieldSelection, defaultValue as FieldSelection);
39+
} else {
40+
result[key] = value;
41+
}
42+
}
43+
return result;
44+
};
45+
46+
const resolveSelection = (defaultSelection: FieldSelection, userSelection?: FieldSelection | null): FieldSelection => {
47+
if (!userSelection) return defaultSelection;
48+
return normalizeSelection(userSelection, defaultSelection);
49+
};
50+
2751
const directivesForOptions = (options?: BaseFindOptions | null) => {
2852
if (options?.live) return ["@live"];
2953
return undefined;
@@ -41,7 +65,7 @@ export const findOneOperation = (
4165
if (typeof id !== "undefined") variables.id = Var({ type: "GadgetID!", value: id });
4266

4367
let fields = {
44-
[operation]: Call(variables, fieldSelectionToQueryCompilerFields(options?.select || defaultSelection, true)),
68+
[operation]: Call(variables, fieldSelectionToQueryCompilerFields(resolveSelection(defaultSelection, options?.select), true)),
4569
};
4670

4771
fields = namespacify(namespace, fields);
@@ -105,7 +129,7 @@ export const findManyOperation = (
105129
pageInfo: { hasNextPage: true, hasPreviousPage: true, startCursor: true, endCursor: true },
106130
edges: {
107131
cursor: true,
108-
node: fieldSelectionToQueryCompilerFields(options?.select || defaultSelection, true),
132+
node: fieldSelectionToQueryCompilerFields(resolveSelection(defaultSelection, options?.select), true),
109133
},
110134
}
111135
),
@@ -176,7 +200,7 @@ export const actionOperation = (
176200
isBulkAction?: boolean | null,
177201
hasReturnType?: HasReturnType | null
178202
) => {
179-
const selection = options?.select || defaultSelection;
203+
const selection = resolveSelection(defaultSelection!, options?.select);
180204

181205
let fields: BuilderFieldSelection = {
182206
[operation]: Call(
@@ -223,7 +247,7 @@ export const backgroundActionResultOperation = <Action extends AnyActionFunction
223247

224248
switch (backgroundAction.type) {
225249
case "action": {
226-
const selection = options?.select || backgroundAction.defaultSelection;
250+
const selection = resolveSelection(backgroundAction.defaultSelection, options?.select);
227251

228252
fields = {
229253
[`... on ${resultType}`]: actionResultFieldSelection(

packages/react/spec/auto/hooks/helpers.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import { GadgetFieldType } from "../../../src/internal/gql/graphql.js";
33
import type { FieldMetadata } from "../../../src/metadata.js";
44
import {
55
fieldMetadataArrayToFieldMetadataTree,
6+
fileSelection,
67
getTableColumns,
78
getTableRows,
89
getTableSelectionMap,
10+
richTextSelection,
11+
roleAssignmentsSelection,
912
} from "../../../src/use-table/helpers.js";
1013
import type { RelationshipType, TableSpec } from "../../../src/use-table/types.js";
1114

@@ -498,6 +501,49 @@ describe("helper functions for useTable hook", () => {
498501
});
499502
});
500503
});
504+
505+
describe("exported selection constants", () => {
506+
it("richTextSelection should contain markdown and truncatedHTML", () => {
507+
expect(richTextSelection).toEqual({
508+
markdown: true,
509+
truncatedHTML: true,
510+
});
511+
});
512+
513+
it("fileSelection should contain url, mimeType, and fileName", () => {
514+
expect(fileSelection).toEqual({
515+
url: true,
516+
mimeType: true,
517+
fileName: true,
518+
});
519+
});
520+
521+
it("roleAssignmentsSelection should contain key and name", () => {
522+
expect(roleAssignmentsSelection).toEqual({
523+
key: true,
524+
name: true,
525+
});
526+
});
527+
528+
it("getTableSelectionMap uses the exported selection constants for special field types", () => {
529+
const result = getTableSelectionMap({
530+
targetColumns: ["description", "image", "roles"],
531+
fieldMetadataTree: fieldMetadataArrayToFieldMetadataTree([
532+
getSimpleFieldMetadata("Description", "description", GadgetFieldType.RichText),
533+
getSimpleFieldMetadata("Image", "image", GadgetFieldType.File),
534+
getSimpleFieldMetadata("Roles", "roles", GadgetFieldType.RoleAssignments),
535+
]),
536+
defaultSelection: {},
537+
});
538+
539+
expect(result).toEqual({
540+
id: true,
541+
description: richTextSelection,
542+
image: fileSelection,
543+
roles: roleAssignmentsSelection,
544+
});
545+
});
546+
});
501547
});
502548

503549
const gadgetGenericFieldConfig = {

packages/react/src/auto/AutoForm.ts

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,15 @@ import type {
2222
UseActionFormSubmit,
2323
} from "../use-action-form/types.js";
2424
import { isPlainObject, processDefaultValues, toDefaultValues } from "../use-action-form/utils.js";
25-
import { getRelatedModelFields, isHasManyOrHasManyThroughField, isRelationshipField, pathListToSelection } from "../use-table/helpers.js";
25+
import {
26+
fileSelection,
27+
getRelatedModelFields,
28+
isHasManyOrHasManyThroughField,
29+
isRelationshipField,
30+
pathListToSelection,
31+
richTextSelection,
32+
roleAssignmentsSelection,
33+
} from "../use-table/helpers.js";
2634
import type { FieldErrors, FieldValues, UseFormReturn } from "../useActionForm.js";
2735
import { useActionForm } from "../useActionForm.js";
2836
import { get, getFlattenedObjectKeys, set } from "../utils.js";
@@ -221,7 +229,7 @@ const useFormSelection = (props: {
221229
if (!select || !modelApiIdentifier) {
222230
return;
223231
}
224-
return forceIdsIntoSelect({ select, rootFieldsMetadata });
232+
return forceRequiredFieldsIntoSelect({ select, rootFieldsMetadata });
225233
}, [select, modelApiIdentifier, rootFieldsMetadata]);
226234

227235
if (!modelApiIdentifier || !fields.length) {
@@ -238,38 +246,56 @@ const useFormSelection = (props: {
238246
return pathListToSelection(modelApiIdentifier, paths, fieldMetaData);
239247
};
240248

241-
const forceIdsIntoSelect = (props: { select: FieldSelection; rootFieldsMetadata: FieldMetadata[] }) => {
249+
const forceRequiredFieldsIntoSelect = (props: { select: FieldSelection; rootFieldsMetadata: FieldMetadata[] }) => {
242250
const { select: originalSelect, rootFieldsMetadata } = props;
243251
const select = structuredClone(originalSelect);
244252

245253
select.id = true; // Always select the ID for the root model
246254

247-
const addIdToSelection = (selectPath: string, fieldMetadata: FieldMetadata) => {
248-
if (!isRelationshipField(fieldMetadata)) {
249-
return; // Non relationships do not need additional selection
250-
}
255+
const addRequiredFieldsToSelection = (selectPath: string, fieldMetadata: FieldMetadata) => {
256+
const isRichTextField = fieldMetadata.fieldType === FieldType.RichText;
257+
const isFileField = fieldMetadata.fieldType === FieldType.File;
258+
const isRolesField = fieldMetadata.fieldType === FieldType.RoleAssignments;
259+
const isRelationship = isRelationshipField(fieldMetadata);
251260

252261
const existingSelection = get(select, selectPath);
253-
if (!existingSelection || typeof existingSelection !== "object") {
254-
// Do not go deeper than what is defined in the select object
255-
return;
262+
if (!existingSelection) {
263+
return; // Do not select at all
256264
}
257265

258-
const isManyRelation = isHasManyOrHasManyThroughField(fieldMetadata);
259-
const currentFieldSelectPathPrefix = isManyRelation ? `${selectPath}.edges.node` : `${selectPath}`;
260-
const idPath = `${currentFieldSelectPathPrefix}.id`;
266+
if (isRichTextField) {
267+
return set(select, selectPath, richTextSelection); // Assume that the whole rich text is expected to be selected
268+
}
261269

262-
set(select, idPath, true);
270+
if (isFileField) {
271+
return set(select, selectPath, fileSelection); // Assume whole file is expected to be selected
272+
}
273+
if (isRolesField) {
274+
return set(select, selectPath, roleAssignmentsSelection); // Assume whole role assignments are expected to be selected
275+
}
263276

264-
const relatedModelFields = getRelatedModelFields(fieldMetadata);
277+
if (isRelationship) {
278+
if (typeof existingSelection !== "object") {
279+
// Do not go deeper than what is defined in the select object
280+
return;
281+
}
282+
283+
const isManyRelation = isHasManyOrHasManyThroughField(fieldMetadata);
284+
const currentFieldSelectPathPrefix = isManyRelation ? `${selectPath}.edges.node` : `${selectPath}`;
285+
const idPath = `${currentFieldSelectPathPrefix}.id`;
286+
287+
set(select, idPath, true);
265288

266-
for (const relatedModelField of relatedModelFields) {
267-
addIdToSelection(`${currentFieldSelectPathPrefix}.${relatedModelField.apiIdentifier}`, relatedModelField);
289+
const relatedModelFields = getRelatedModelFields(fieldMetadata);
290+
291+
for (const relatedModelField of relatedModelFields) {
292+
addRequiredFieldsToSelection(`${currentFieldSelectPathPrefix}.${relatedModelField.apiIdentifier}`, relatedModelField);
293+
}
268294
}
269295
};
270296

271297
for (const field of rootFieldsMetadata) {
272-
addIdToSelection(field.apiIdentifier, field);
298+
addRequiredFieldsToSelection(field.apiIdentifier, field);
273299
}
274300

275301
return select;

packages/react/src/use-table/helpers.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -379,18 +379,18 @@ export const isRelationshipField = (field: { fieldType: GadgetFieldType }) => {
379379
return isHasOneOrBelongsToField(field) || isHasManyOrHasManyThroughField(field);
380380
};
381381

382-
const richTextSelection = {
382+
export const richTextSelection = {
383383
markdown: true,
384384
truncatedHTML: true,
385385
};
386386

387-
const fileSelection = {
387+
export const fileSelection = {
388388
url: true,
389389
mimeType: true,
390390
fileName: true,
391391
};
392392

393-
const roleAssignmentsSelection = {
393+
export const roleAssignmentsSelection = {
394394
key: true,
395395
name: true,
396396
};

0 commit comments

Comments
 (0)