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
14 changes: 14 additions & 0 deletions .changeset/27567-import-match-on-list-fields.md
Original file line number Diff line number Diff line change
@@ -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-②: 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.

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.
3 changes: 3 additions & 0 deletions ui/packages/app-shell/src/console/ai/ExcelImportBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface WizardField {
type: string;
required?: boolean;
options?: Array<{ label?: string; value?: string | number } | string>;
multiple?: boolean;
}

interface ExcelImportBarProps {
Expand Down Expand Up @@ -71,6 +72,8 @@ function normalizeFields(schema: any): WizardField[] {
type: (def?.type ?? 'text') as string,
required: !!def?.required,
options: def?.options,
// 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));
}
Expand Down
12 changes: 12 additions & 0 deletions ui/packages/app-shell/src/views/importTargetFields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/app-shell/src/views/importTargetFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface ImportTargetField {
required?: boolean;
matchOnly?: boolean;
options?: unknown;
multiple?: boolean;
}

/** Minimal slice of the permissions channel this helper consults. */
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = vi.fn();
}
});

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(
<ImportWizard
objectName="deal"
fields={FIELDS}
dataSource={{}}
open
onOpenChange={() => {}}
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']);
});
});
33 changes: 27 additions & 6 deletions ui/packages/plugin-grid/src/ImportWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -605,6 +611,17 @@ function formatDryRunError(
type ImportTargetField = ImportWizardProps['fields'][number];
type ImportTargetOption = NonNullable<ImportTargetField['options']>[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<ImportTargetField, 'type' | 'multiple'>): 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,])[^,]*$/;

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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 (
Expand Down
Loading