From e5041d8aa46e07c913e9c0fe83cebe8b6fb56c7d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:47:46 +0000 Subject: [PATCH 1/4] fix(plugin-grid): "Match on" offers only fields the server takes as a match key (#27567) The import wizard's options step listed every mapped field under "Match on". The server refuses a list or structured field as a match key, so the list now filters on the same spec definitions (isMultiValueField, STRUCTURED_JSON_TYPES), and the console's field-shape builders carry the schema's `multiple` flag. Claude-Session: https://claude.ai/code/session_01Utp6CXKADE9Bt6J32Lacv5 Co-authored-by: Claude --- .../27567-import-match-on-list-fields.md | 14 ++++ .../src/console/ai/ExcelImportBar.tsx | 2 + .../src/views/importTargetFields.test.ts | 12 +++ .../app-shell/src/views/importTargetFields.ts | 3 + ...ImportWizard.matchKeyFields-27567.test.tsx | 76 +++++++++++++++++++ ui/packages/plugin-grid/src/ImportWizard.tsx | 33 ++++++-- 6 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 .changeset/27567-import-match-on-list-fields.md create mode 100644 ui/packages/plugin-grid/src/ImportWizard.matchKeyFields-27567.test.tsx diff --git a/.changeset/27567-import-match-on-list-fields.md b/.changeset/27567-import-match-on-list-fields.md new file mode 100644 index 00000000000..59c34363b7a --- /dev/null +++ b/.changeset/27567-import-match-on-list-fields.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-grid': patch +--- + +fix(plugin-grid): the import wizard's "Match on" no longer offers a field the server refuses as a match key (#27567) + +Clause-②: no +Metadata-protocol: none + +In "Update existing" and "Update if matched, else create", "Match on" listed every mapped field. The server refuses a match key whose value is a list or a structured payload (`400 INVALID_REQUEST`, #27465), so ticking such a field got the whole import refused at Validate. The list now leaves those fields out, by the same spec definitions the server's `multiValueMatchKeyRefusal` reads (`isMultiValueField`, `STRUCTURED_JSON_TYPES` from `@objectstack/spec/data`): a `multiple: true` lookup, user, select, radio, file or image field; a multiselect, checkboxes or tags field; and a JSON-stored type such as `json` or `address`. Single-value fields are offered as before. + +The wizard's field shape (`ImportWizardProps['fields']`) takes an optional `multiple` flag, carried from the object schema by the console's import targets; a host that leaves it out still has the inherently multi and structured types filtered by type. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/console/ai/ExcelImportBar.tsx b/ui/packages/app-shell/src/console/ai/ExcelImportBar.tsx index 94d105b87d5..3e7733cbf70 100644 --- a/ui/packages/app-shell/src/console/ai/ExcelImportBar.tsx +++ b/ui/packages/app-shell/src/console/ai/ExcelImportBar.tsx @@ -42,6 +42,7 @@ interface WizardField { type: string; required?: boolean; options?: Array<{ label?: string; value?: string | number } | string>; + multiple?: boolean; } interface ExcelImportBarProps { @@ -71,6 +72,7 @@ function normalizeFields(schema: any): WizardField[] { type: (def?.type ?? 'text') as string, required: !!def?.required, options: def?.options, + multiple: def?.multiple === true, })) .filter((f) => f.name && !NON_WRITABLE_TYPES.includes(f.type)); } diff --git a/ui/packages/app-shell/src/views/importTargetFields.test.ts b/ui/packages/app-shell/src/views/importTargetFields.test.ts index 2d3e32de359..b49e39335d5 100644 --- a/ui/packages/app-shell/src/views/importTargetFields.test.ts +++ b/ui/packages/app-shell/src/views/importTargetFields.test.ts @@ -77,6 +77,18 @@ describe('importTargetFields', () => { expect(editOnly.find((f) => f.name === 'name')).toMatchObject({ matchOnly: true, required: false }); }); + it('#27567: carries the schema\'s `multiple: true`, so "Match on" can leave a list field out', () => { + const out = importTargetFields('device', { + owners: { type: 'lookup', label: 'Owners', reference: 'user', multiple: true }, + owner: { type: 'lookup', label: 'Owner', reference: 'user', multiple: false }, + name: { type: 'text', label: 'Name' }, + }); + const byName = Object.fromEntries(out.map((f) => [f.name, f])); + expect(byName.owners.multiple).toBe(true); + expect(byName.owner.multiple).toBeUndefined(); + expect(byName.name.multiple).toBeUndefined(); + }); + it('without a loaded perms channel, falls back to schema-only derivation', () => { const out = importTargetFields('device', fields, { isLoaded: false, checkField: () => false }); expect(out.map((f) => f.name).sort()).toEqual( diff --git a/ui/packages/app-shell/src/views/importTargetFields.ts b/ui/packages/app-shell/src/views/importTargetFields.ts index f910e7ccebc..96ff1161a0d 100644 --- a/ui/packages/app-shell/src/views/importTargetFields.ts +++ b/ui/packages/app-shell/src/views/importTargetFields.ts @@ -32,6 +32,7 @@ export interface ImportTargetField { required?: boolean; matchOnly?: boolean; options?: unknown; + multiple?: boolean; } /** Minimal slice of the permissions channel this helper consults. */ @@ -67,6 +68,8 @@ export function importTargetFields( ...(matchOnly ? { matchOnly: true } : {}), // Enum options seed the downloadable template's example row. ...(def?.options ? { options: def.options } : {}), + // A field holding a list is never offered as a match key (#27567). + ...(def?.multiple === true ? { multiple: true } : {}), }); } return out; diff --git a/ui/packages/plugin-grid/src/ImportWizard.matchKeyFields-27567.test.tsx b/ui/packages/plugin-grid/src/ImportWizard.matchKeyFields-27567.test.tsx new file mode 100644 index 00000000000..d34bb5a8b34 --- /dev/null +++ b/ui/packages/plugin-grid/src/ImportWizard.matchKeyFields-27567.test.tsx @@ -0,0 +1,76 @@ +/** + * "Match on" offers only the mapped fields the server takes as a match key + * (objectstack#27567). + * + * The server refuses a match key whose value is a list or a structured payload + * (`multiValueMatchKeyRefusal`, objectstack#27465): a `multiple: true` + * lookup or select, a multiselect / tags / checkboxes field, and a JSON-stored + * type. The wizard offered every mapped field, so the user could tick one the + * import then refused at Validate. It now reads the same spec definitions. + */ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { ImportWizard } from './ImportWizard'; + +beforeAll(() => { + // Radix Select reads these on open; happy-dom lacks them. + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = vi.fn(() => false) as any; + } + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +const FIELDS = [ + { name: 'code', label: 'code', type: 'text' }, + { name: 'email', label: 'email', type: 'email' }, + { name: 'owner', label: 'owner', type: 'lookup' }, + { name: 'owners', label: 'owners', type: 'lookup', multiple: true }, + { name: 'stage', label: 'stage', type: 'select', options: ['a', 'b'] }, + { name: 'stages', label: 'stages', type: 'select', options: ['a', 'b'], multiple: true }, + { name: 'labels', label: 'labels', type: 'tags' }, + { name: 'regions', label: 'regions', type: 'multiselect', options: ['a', 'b'] }, + { name: 'meta', label: 'meta', type: 'json' }, + { name: 'site', label: 'site', type: 'address' }, +]; + +/** Paste TSV into the upload step via the wizard's window-level handler. */ +function pasteRows(text: string) { + const evt = new Event('paste', { bubbles: true, cancelable: true }) as Event & { + clipboardData: { getData: (type: string) => string }; + }; + evt.clipboardData = { getData: (type: string) => (type === 'text/plain' ? text : '') }; + act(() => { window.dispatchEvent(evt); }); +} + +async function toMatchFields() { + const headers = FIELDS.map((f) => f.name); + pasteRows(`${headers.join('\t')}\n${headers.map(() => 'x').join('\t')}`); + fireEvent.click(await screen.findByTestId('import-next-btn')); + return screen.findByTestId('import-matchfields'); +} + +describe('"Match on" offers only the fields the server takes as a match key (objectstack#27567)', () => { + it('leaves out a list or structured field and offers the single-value ones', async () => { + render( + {}} + writeModes={['upsert']} + />, + ); + await toMatchFields(); + + const offered = FIELDS + .map((f) => f.name) + .filter((name) => screen.queryByTestId(`import-matchfield-${name}`) !== null); + expect(offered).toEqual(['code', 'email', 'owner', 'stage']); + }); +}); diff --git a/ui/packages/plugin-grid/src/ImportWizard.tsx b/ui/packages/plugin-grid/src/ImportWizard.tsx index d730a00c7b8..bff5282cf05 100644 --- a/ui/packages/plugin-grid/src/ImportWizard.tsx +++ b/ui/packages/plugin-grid/src/ImportWizard.tsx @@ -16,7 +16,8 @@ import { resolveAffordance, sanitizeFileNameBase } from '@object-ui/core'; import { useDisplayLocale } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; import { - BOOLEAN_VALUE_TYPES, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES, SINGLE_OPTION_TYPES, + BOOLEAN_VALUE_TYPES, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES, SINGLE_OPTION_TYPES, STRUCTURED_JSON_TYPES, + isMultiValueField, } from '@objectstack/spec/data'; import { BOOLEAN_IMPORT_TOKENS, REFERENCE_IMPORT_TYPES, isIdentityEmail, isImportableDateCell, isImportableTimeCell, @@ -362,6 +363,11 @@ export interface ImportWizardProps { /** Allowed values for select/enum fields. Accepts option objects or bare * strings. */ options?: Array<{ label?: string; value?: string | number } | string>; + /** The object schema's `multiple` flag: a `select` / `radio` / `lookup` / + * `user` / `file` / `image` field flagged `multiple: true` stores a list. + * "Match on" reads it, as the server does, to leave such a field out + * (`isMatchKeyField`). */ + multiple?: boolean; /** Storage-backed but non-writable target (autonumber / readonly): mappable * so update/upsert can MATCH rows on it (e.g. "update the row whose record * number already exists"), rendered with a "(match only)" hint. The value @@ -605,6 +611,17 @@ function formatDryRunError( type ImportTargetField = ImportWizardProps['fields'][number]; type ImportTargetOption = NonNullable[number]; +/** + * Whether the server takes this field as a match key. It refuses a field whose + * value is a list or a structured payload, as one value per field is what a + * match key compares: the server's `multiValueMatchKeyRefusal` reads the same + * two spec definitions, `STRUCTURED_JSON_TYPES` and `isMultiValueField`. + */ +function isMatchKeyField(field: Pick): boolean { + if (STRUCTURED_JSON_TYPES.has(field.type)) return false; + return !isMultiValueField({ type: field.type, multiple: field.multiple === true }); +} + /** The only comma a numeric cell may carry: a thousands grouping of its integer part. */ const THOUSANDS_GROUPED_INTEGER = /^[+-]?\d{1,3}(?:,\d{3})+(?![\d,])[^,]*$/; @@ -671,9 +688,9 @@ function splitImportMultiValue(cell: string): string[] { * * One reading is not the server's exactly, and it errs towards taking a cell: * a `select` / `radio` flagged `multiple: true` is split by the server as well, - * and the wizard's field shape does not carry `multiple`. So a single-option - * cell is refused only when both readings refuse it: as one value, and as a - * split list. + * and a host may leave the field shape's `multiple` out, so this check does not + * read it. So a single-option cell is refused only when both readings refuse + * it: as one value, and as a split list. * * With *Keep unknown option values* on (the request's `createMissingOptions`), * the server takes every option cell, on a writable field and a `matchOnly` @@ -1962,9 +1979,13 @@ const ImportOptions: React.FC<{ showBackground, backgroundImport, onBackgroundImport, }) => { const { t } = useImportTranslation(); - // Only fields that are actually mapped can serve as match keys. + // Only fields that are actually mapped, and that the server takes as a + // match key, can serve as match keys. const mappedFieldNames = useMemo(() => new Set(Object.values(mapping)), [mapping]); - const matchable = useMemo(() => fields.filter((f) => mappedFieldNames.has(f.name)), [fields, mappedFieldNames]); + const matchable = useMemo( + () => fields.filter((f) => mappedFieldNames.has(f.name) && isMatchKeyField(f)), + [fields, mappedFieldNames], + ); const needsMatch = writeMode !== 'insert'; return ( From 9ee8b7bb8959b71a23d46ca234826cfeb1e096b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:49:26 +0000 Subject: [PATCH 2/4] =?UTF-8?q?chore(changeset):=20#27567=20declares=20Cla?= =?UTF-8?q?use-=E2=91=A1:=20yes=20(widening)=20for=20the=20new=20optional?= =?UTF-8?q?=20`multiple`=20prop=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Utp6CXKADE9Bt6J32Lacv5 Co-authored-by: Claude --- .changeset/27567-import-match-on-list-fields.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/27567-import-match-on-list-fields.md b/.changeset/27567-import-match-on-list-fields.md index 59c34363b7a..842d2ebd4b4 100644 --- a/.changeset/27567-import-match-on-list-fields.md +++ b/.changeset/27567-import-match-on-list-fields.md @@ -4,7 +4,7 @@ fix(plugin-grid): the import wizard's "Match on" no longer offers a field the server refuses as a match key (#27567) -Clause-②: no +Clause-②: yes (widening) Metadata-protocol: none In "Update existing" and "Update if matched, else create", "Match on" listed every mapped field. The server refuses a match key whose value is a list or a structured payload (`400 INVALID_REQUEST`, #27465), so ticking such a field got the whole import refused at Validate. The list now leaves those fields out, by the same spec definitions the server's `multiValueMatchKeyRefusal` reads (`isMultiValueField`, `STRUCTURED_JSON_TYPES` from `@objectstack/spec/data`): a `multiple: true` lookup, user, select, radio, file or image field; a multiselect, checkboxes or tags field; and a JSON-stored type such as `json` or `address`. Single-value fields are offered as before. From 06d7683982f001f04cffc1010388f651282fad94 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:58:38 +0000 Subject: [PATCH 3/4] fix(app-shell): the AI Excel import carries `multiple` only when true, as the console list does (#27567) Claude-Session: https://claude.ai/code/session_01Utp6CXKADE9Bt6J32Lacv5 Co-authored-by: Claude --- ui/packages/app-shell/src/console/ai/ExcelImportBar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/console/ai/ExcelImportBar.tsx b/ui/packages/app-shell/src/console/ai/ExcelImportBar.tsx index 3e7733cbf70..d99b338397b 100644 --- a/ui/packages/app-shell/src/console/ai/ExcelImportBar.tsx +++ b/ui/packages/app-shell/src/console/ai/ExcelImportBar.tsx @@ -72,7 +72,8 @@ function normalizeFields(schema: any): WizardField[] { type: (def?.type ?? 'text') as string, required: !!def?.required, options: def?.options, - multiple: def?.multiple === true, + // A field holding a list is never offered as a match key (#27567). + ...(def?.multiple === true ? { multiple: true } : {}), })) .filter((f) => f.name && !NON_WRITABLE_TYPES.includes(f.type)); } From 4c8c0836ab78efb335d6db4a968acb2a28ee50a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:20:12 +0000 Subject: [PATCH 4/4] test(plugin-grid): the #27567 pin stubs the Radix pointer helpers without `as any` Claude-Session: https://claude.ai/code/session_01Utp6CXKADE9Bt6J32Lacv5 Co-authored-by: Claude --- .../src/ImportWizard.matchKeyFields-27567.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/packages/plugin-grid/src/ImportWizard.matchKeyFields-27567.test.tsx b/ui/packages/plugin-grid/src/ImportWizard.matchKeyFields-27567.test.tsx index d34bb5a8b34..95ecb6e19ea 100644 --- a/ui/packages/plugin-grid/src/ImportWizard.matchKeyFields-27567.test.tsx +++ b/ui/packages/plugin-grid/src/ImportWizard.matchKeyFields-27567.test.tsx @@ -18,10 +18,10 @@ import { ImportWizard } from './ImportWizard'; beforeAll(() => { // Radix Select reads these on open; happy-dom lacks them. if (!Element.prototype.hasPointerCapture) { - Element.prototype.hasPointerCapture = vi.fn(() => false) as any; + Element.prototype.hasPointerCapture = vi.fn(() => false); } if (!Element.prototype.scrollIntoView) { - Element.prototype.scrollIntoView = vi.fn() as any; + Element.prototype.scrollIntoView = vi.fn(); } });