Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 0 additions & 94 deletions packages/api-client-core/spec/operationBuilders.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1621,98 +1621,4 @@ 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");
});
});
});
});
32 changes: 4 additions & 28 deletions packages/api-client-core/src/operationBuilders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,30 +24,6 @@ const fieldSelectionToQueryCompilerFields = (selection: FieldSelection, includeT

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

/**
* 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;
Expand All @@ -65,7 +41,7 @@ export const findOneOperation = (
if (typeof id !== "undefined") variables.id = Var({ type: "GadgetID!", value: id });

let fields = {
[operation]: Call(variables, fieldSelectionToQueryCompilerFields(resolveSelection(defaultSelection, options?.select), true)),
[operation]: Call(variables, fieldSelectionToQueryCompilerFields(options?.select || defaultSelection, true)),
};

fields = namespacify(namespace, fields);
Expand Down Expand Up @@ -129,7 +105,7 @@ export const findManyOperation = (
pageInfo: { hasNextPage: true, hasPreviousPage: true, startCursor: true, endCursor: true },
edges: {
cursor: true,
node: fieldSelectionToQueryCompilerFields(resolveSelection(defaultSelection, options?.select), true),
node: fieldSelectionToQueryCompilerFields(options?.select || defaultSelection, true),
},
}
),
Expand Down Expand Up @@ -200,7 +176,7 @@ export const actionOperation = (
isBulkAction?: boolean | null,
hasReturnType?: HasReturnType | null
) => {
const selection = resolveSelection(defaultSelection!, options?.select);
const selection = options?.select || defaultSelection;

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

switch (backgroundAction.type) {
case "action": {
const selection = resolveSelection(backgroundAction.defaultSelection, options?.select);
const selection = options?.select || backgroundAction.defaultSelection;

fields = {
[`... on ${resultType}`]: actionResultFieldSelection(
Expand Down
46 changes: 0 additions & 46 deletions packages/react/spec/auto/hooks/helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,9 @@ import { GadgetFieldType } from "../../../src/internal/gql/graphql.js";
import type { FieldMetadata } from "../../../src/metadata.js";
import {
fieldMetadataArrayToFieldMetadataTree,
fileSelection,
getTableColumns,
getTableRows,
getTableSelectionMap,
richTextSelection,
roleAssignmentsSelection,
} from "../../../src/use-table/helpers.js";
import type { RelationshipType, TableSpec } from "../../../src/use-table/types.js";

Expand Down Expand Up @@ -501,49 +498,6 @@ describe("helper functions for useTable hook", () => {
});
});
});

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 = {
Expand Down
62 changes: 18 additions & 44 deletions packages/react/src/auto/AutoForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,7 @@ import type {
UseActionFormSubmit,
} from "../use-action-form/types.js";
import { isPlainObject, processDefaultValues, toDefaultValues } from "../use-action-form/utils.js";
import {
fileSelection,
getRelatedModelFields,
isHasManyOrHasManyThroughField,
isRelationshipField,
pathListToSelection,
richTextSelection,
roleAssignmentsSelection,
} from "../use-table/helpers.js";
import { getRelatedModelFields, isHasManyOrHasManyThroughField, isRelationshipField, pathListToSelection } 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";
Expand Down Expand Up @@ -229,7 +221,7 @@ const useFormSelection = (props: {
if (!select || !modelApiIdentifier) {
return;
}
return forceRequiredFieldsIntoSelect({ select, rootFieldsMetadata });
return forceIdsIntoSelect({ select, rootFieldsMetadata });
}, [select, modelApiIdentifier, rootFieldsMetadata]);

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

const forceRequiredFieldsIntoSelect = (props: { select: FieldSelection; rootFieldsMetadata: FieldMetadata[] }) => {
const forceIdsIntoSelect = (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 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) {
return; // Do not select at all
}

if (isRichTextField) {
return set(select, selectPath, richTextSelection); // Assume that the whole rich text is expected to be selected
const addIdToSelection = (selectPath: string, fieldMetadata: FieldMetadata) => {
if (!isRelationshipField(fieldMetadata)) {
return; // Non relationships do not need additional selection
}

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 existingSelection = get(select, selectPath);
if (!existingSelection || typeof existingSelection !== "object") {
// Do not go deeper than what is defined in the select object
return;
}

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`;

const isManyRelation = isHasManyOrHasManyThroughField(fieldMetadata);
const currentFieldSelectPathPrefix = isManyRelation ? `${selectPath}.edges.node` : `${selectPath}`;
const idPath = `${currentFieldSelectPathPrefix}.id`;
set(select, idPath, true);

set(select, idPath, true);
const relatedModelFields = getRelatedModelFields(fieldMetadata);

const relatedModelFields = getRelatedModelFields(fieldMetadata);

for (const relatedModelField of relatedModelFields) {
addRequiredFieldsToSelection(`${currentFieldSelectPathPrefix}.${relatedModelField.apiIdentifier}`, relatedModelField);
}
for (const relatedModelField of relatedModelFields) {
addIdToSelection(`${currentFieldSelectPathPrefix}.${relatedModelField.apiIdentifier}`, relatedModelField);
}
};

for (const field of rootFieldsMetadata) {
addRequiredFieldsToSelection(field.apiIdentifier, field);
addIdToSelection(field.apiIdentifier, field);
}

return select;
Expand Down
6 changes: 3 additions & 3 deletions packages/react/src/use-table/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,18 +379,18 @@ export const isRelationshipField = (field: { fieldType: GadgetFieldType }) => {
return isHasOneOrBelongsToField(field) || isHasManyOrHasManyThroughField(field);
};

export const richTextSelection = {
const richTextSelection = {
markdown: true,
truncatedHTML: true,
};

export const fileSelection = {
const fileSelection = {
url: true,
mimeType: true,
fileName: true,
};

export const roleAssignmentsSelection = {
const roleAssignmentsSelection = {
key: true,
name: true,
};
Expand Down
Loading