From a2925d65fa75fa005fe9371b7ee1a42ad6443fc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:45:28 +0000 Subject: [PATCH 1/3] fix(plugin-grid): the import preview checks a time column and the user import's email as the server does The preview marks a cell exactly where the server's import refuses it, and the import button counts the rows nobody marked (objectui#11814, #11889). Two columns still went unchecked: - A `time` cell: `checkImportCell` had no `time` arm. It now mirrors core's `parseDateCell(cell, 'time')` (`readTimeOfDayCell`, then the year-first form) and refuses with `invalid_time`, in a new localized sentence `grid.import.invalidTime` in every pack. - The user import's email: `identityImportFields` typed it `text`. It is now typed `email` and flagged `emailRule: 'identity'`, and the email arm asks the user import endpoint's rule (`isLikelyEmail` and `isPlaceholderEmail`) instead of the record validator's. Refs objectui#11913. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../11913-import-time-identity-email.md | 15 +++ .../src/views/IdentityImportPanels.tsx | 12 +- ...ntityImportFields.emailRule-11913.test.tsx | 71 ++++++++++ packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + .../src/ImportWizard.cellCheck-11913.test.tsx | 121 ++++++++++++++++++ packages/plugin-grid/src/ImportWizard.tsx | 25 +++- .../src/importCoercionContract.test.ts | 80 ++++++++++++ .../plugin-grid/src/importCoercionContract.ts | 112 +++++++++++++++- 17 files changed, 434 insertions(+), 12 deletions(-) create mode 100644 .changeset/11913-import-time-identity-email.md create mode 100644 packages/app-shell/src/views/__tests__/identityImportFields.emailRule-11913.test.tsx create mode 100644 packages/plugin-grid/src/ImportWizard.cellCheck-11913.test.tsx diff --git a/.changeset/11913-import-time-identity-email.md b/.changeset/11913-import-time-identity-email.md new file mode 100644 index 0000000000..6a44c0a8d9 --- /dev/null +++ b/.changeset/11913-import-time-identity-email.md @@ -0,0 +1,15 @@ +--- +'@object-ui/plugin-grid': minor +'@object-ui/i18n': minor +'@object-ui/app-shell': patch +--- + +The Import Wizard's preview checks a `time` column, and the user import's email column, the way the server's import judges them, so the import button no longer counts rows the server then refuses (objectui#11913). + +- **A `time` column is checked.** A cell is marked where the server's `parseDateCell` reads no time of day from it and the import refuses it with `invalid_time`. The server takes a wall clock (`10:00`, `09:30:15`), an ISO 8601 day or date-time (stored as its UTC time of day) and a year-first date. It refuses `25:00`, `abc`, `10:00Z` and `9am`, and the preview now marks them with the sentence `"{{value}}" is not a valid time` (in English). An `invalid_time` row error from the server's dry run (Validate data) is shown with the same sentence. +- **The user import's email column is checked by the user import endpoint's rule.** That endpoint refuses an address that is not printable ASCII, is longer than 254 characters, or sits on the placeholder domain, with `INVALID_EMAIL`. The column used to reach the wizard typed as text, so the preview checked nothing there. It is now typed `email` and checked by that rule instead of the record validator's. The two rules differ both ways: the record rule takes a non-ASCII domain such as `735431496@柴仟.com`, and the endpoint's rule takes `a@b..c`. + +Additions to the published surface: + +- `@object-ui/plugin-grid`: `ImportWizardProps['fields'][number]` gains an optional `emailRule?: 'identity'`. Left out, an `email` column is checked by the record validator's rule, as before. Set to `'identity'`, it is checked by the user import endpoint's rule. +- `@object-ui/i18n`: every language pack gains `grid.import.invalidTime`, with a `{{value}}` placeholder. diff --git a/packages/app-shell/src/views/IdentityImportPanels.tsx b/packages/app-shell/src/views/IdentityImportPanels.tsx index 1192372d67..4e6839241f 100644 --- a/packages/app-shell/src/views/IdentityImportPanels.tsx +++ b/packages/app-shell/src/views/IdentityImportPanels.tsx @@ -12,6 +12,7 @@ import { } from '@object-ui/components'; import { Download, KeyRound, ShieldAlert } from 'lucide-react'; import type { ImportRecordsResult } from '@object-ui/types'; +import type { ImportWizardProps } from '@object-ui/plugin-grid'; import { useSafeTranslate } from '@object-ui/i18n'; import { buildTemporaryPasswordCsv, @@ -127,13 +128,18 @@ export function IdentityImportResultExtra({ serverResult }: { serverResult?: Imp /** Curated importable columns for sys_user. The generic writable-field filter * would drop `phone_number` (readonly for CRUD — identity writes go through - * better-auth), so identity import declares its own target set. */ + * better-auth), so identity import declares its own target set. + * + * The email column is typed `email` and flagged `emailRule: 'identity'`, so + * the wizard's preview checks it by the identity endpoint's rule: a non-ASCII + * or placeholder address is marked, as the endpoint refuses it with + * `INVALID_EMAIL` (objectui#11913). */ export function identityImportFields( objectFields: Record | undefined, -): Array<{ name: string; label: string; type: string; required?: boolean }> { +): ImportWizardProps['fields'] { const label = (name: string, fallback: string) => objectFields?.[name]?.label || fallback; return [ - { name: 'email', label: label('email', 'Email'), type: 'text' }, + { name: 'email', label: label('email', 'Email'), type: 'email', emailRule: 'identity' }, { name: 'phone_number', label: label('phone_number', 'Phone Number'), type: 'text' }, { name: 'name', label: label('name', 'Name'), type: 'text' }, { name: 'role', label: label('role', 'Platform Role'), type: 'text' }, diff --git a/packages/app-shell/src/views/__tests__/identityImportFields.emailRule-11913.test.tsx b/packages/app-shell/src/views/__tests__/identityImportFields.emailRule-11913.test.tsx new file mode 100644 index 0000000000..f2419347dc --- /dev/null +++ b/packages/app-shell/src/views/__tests__/identityImportFields.emailRule-11913.test.tsx @@ -0,0 +1,71 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The user import's preview checks its email column by the identity + * endpoint's rule, and its import button follows that check (objectui#11913). + * + * `/api/v1/auth/admin/import-users` refuses a non-ASCII or placeholder address + * with `INVALID_EMAIL` (plugin-auth's `isLikelyEmail` / `isPlaceholderEmail`), + * in its dry run too. `identityImportFields` once typed that column `text`, so + * the preview checked nothing there and the button counted those rows until + * Validate was clicked. + * + * The real `ImportWizard` is driven with the real `identityImportFields`. + * `ImportWizard` is imported at module scope rather than through `ObjectView`'s + * `React.lazy` boundary (AGENTS.md 测试纪律), so its cost lands in the import + * phase, which no test timeout bounds. + */ +import { describe, it, expect } from 'vitest'; +import { render, screen, act, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { ImportWizard } from '@object-ui/plugin-grid'; +import { identityImportFields } from '../IdentityImportPanels'; + +/** 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); }); +} + +const cell = (row: number, col: number) => screen.getByTestId(`import-preview-cell-${row}-${col}`); + +describe('identityImportFields: the email column is checked by the identity rule (objectui#11913)', () => { + it('types the email column `email` and flags it for the identity rule', () => { + const email = identityImportFields(undefined).find((f) => f.name === 'email'); + expect(email).toMatchObject({ type: 'email', emailRule: 'identity' }); + }); + + it('marks a non-ASCII and a placeholder address, and the button counts the clean row only', async () => { + render( + {}} + />, + ); + pasteRows('Email\tName\nada@example.com\tAda\n735431496@柴仟.com\tLi\nu-abc@placeholder.invalid\tPhone only'); + const next = await screen.findByTestId('import-next-btn'); + await waitFor(() => expect(next).toBeEnabled()); + fireEvent.click(next); + const button = await screen.findByTestId('import-run-btn'); + + expect(cell(0, 0)).toHaveAttribute('aria-invalid', 'false'); + expect(cell(1, 0)).toHaveAttribute('aria-invalid', 'true'); + expect(cell(2, 0)).toHaveAttribute('aria-invalid', 'true'); + expect(cell(1, 0).closest('td')).toHaveAttribute('title', 'Invalid email'); + expect(button).toHaveTextContent('Import 1 Row'); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 277bbef78f..5800e78cbc 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -648,6 +648,7 @@ const ar = { invalidBoolean: "«{{value}}» ليست قيمة صواب/خطأ صالحة", invalidNumber: "«{{value}}» ليست رقمًا صالحًا", invalidDate: "«{{value}}» ليست تاريخًا صالحًا", + invalidTime: "«{{value}}» ليست وقتًا صالحًا", invalidOption: "«{{value}}» ليست ضمن الخيارات المسموح بها", requiredValue: "هذا الحقل مطلوب", matchAmbiguous: "يطابق أكثر من سجل موجود — استخدم قيمة فريدة أو معرّف السجل", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 7a4bde4c60..64f2b34d01 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -568,6 +568,7 @@ const de = { invalidBoolean: "„{{value}}“ ist kein gültiger Wahr/Falsch-Wert", invalidNumber: "„{{value}}“ ist keine gültige Zahl", invalidDate: "„{{value}}“ ist kein gültiges Datum", + invalidTime: "„{{value}}“ ist keine gültige Uhrzeit", invalidOption: "„{{value}}“ gehört nicht zu den zulässigen Optionen", requiredValue: "Dieses Feld ist erforderlich", matchAmbiguous: "Passt zu mehreren vorhandenen Datensätzen — verwenden Sie einen eindeutigen Wert oder die Datensatz-ID", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 3e73ce3a33..e2a770a176 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -833,6 +833,7 @@ const en = { invalidBoolean: '"{{value}}" is not a valid true/false value', invalidNumber: '"{{value}}" is not a valid number', invalidDate: '"{{value}}" is not a valid date', + invalidTime: '"{{value}}" is not a valid time', invalidOption: '"{{value}}" is not one of the allowed options', requiredValue: 'This field is required', matchAmbiguous: 'Matches more than one existing record — use a unique value or the record id', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 41bfcadcb8..898d9119b9 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -589,6 +589,7 @@ const es = { invalidBoolean: "«{{value}}» no es un valor verdadero/falso válido", invalidNumber: "«{{value}}» no es un número válido", invalidDate: "«{{value}}» no es una fecha válida", + invalidTime: "«{{value}}» no es una hora válida", invalidOption: "«{{value}}» no es una de las opciones permitidas", requiredValue: "Este campo es obligatorio", matchAmbiguous: "Coincide con más de un registro existente: use un valor único o el id del registro", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index be77a728a7..4b2d5b3955 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -585,6 +585,7 @@ const fr = { invalidBoolean: "« {{value}} » n'est pas une valeur vrai/faux valide", invalidNumber: "« {{value}} » n'est pas un nombre valide", invalidDate: "« {{value}} » n'est pas une date valide", + invalidTime: "« {{value}} » n'est pas une heure valide", invalidOption: "« {{value}} » ne fait pas partie des options autorisées", requiredValue: "Ce champ est obligatoire", matchAmbiguous: "Correspond à plusieurs enregistrements existants — utilisez une valeur unique ou l'identifiant de l'enregistrement", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index f7cb5bff61..e4ab700279 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -568,6 +568,7 @@ const ja = { invalidBoolean: "「{{value}}」は有効な true/false の値ではありません", invalidNumber: "「{{value}}」は有効な数値ではありません", invalidDate: "「{{value}}」は有効な日付ではありません", + invalidTime: "「{{value}}」は有効な時刻ではありません", invalidOption: "「{{value}}」は許可された選択肢に含まれていません", requiredValue: "この項目は必須です", matchAmbiguous: "既存の複数レコードに一致します — 一意の値かレコード ID を使用してください", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 2119ee5d72..6604487080 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -568,6 +568,7 @@ const ko = { invalidBoolean: "\"{{value}}\"은(는) 유효한 true/false 값이 아닙니다", invalidNumber: "\"{{value}}\"은(는) 유효한 숫자가 아닙니다", invalidDate: "\"{{value}}\"은(는) 유효한 날짜가 아닙니다", + invalidTime: "\"{{value}}\"은(는) 유효한 시간이 아닙니다", invalidOption: "\"{{value}}\"은(는) 허용된 선택지에 없습니다", requiredValue: "필수 필드입니다", matchAmbiguous: "기존 레코드 여러 건과 일치합니다 — 고유한 값이나 레코드 ID를 사용하세요", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index bb333be7fe..91a8b0d094 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -584,6 +584,7 @@ const pt = { invalidBoolean: "\"{{value}}\" não é um valor verdadeiro/falso válido", invalidNumber: "\"{{value}}\" não é um número válido", invalidDate: "\"{{value}}\" não é uma data válida", + invalidTime: "\"{{value}}\" não é uma hora válida", invalidOption: "\"{{value}}\" não está entre as opções permitidas", requiredValue: "Este campo é obrigatório", matchAmbiguous: "Corresponde a mais de um registro existente — use um valor único ou o id do registro", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index f18746d820..c188b5e668 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -610,6 +610,7 @@ const ru = { invalidBoolean: "«{{value}}» не является допустимым значением true/false", invalidNumber: "«{{value}}» не является допустимым числом", invalidDate: "«{{value}}» не является допустимой датой", + invalidTime: "«{{value}}» не является допустимым временем", invalidOption: "«{{value}}» не входит в число допустимых вариантов", requiredValue: "Это поле обязательно", matchAmbiguous: "Соответствует нескольким существующим записям — используйте уникальное значение или идентификатор записи", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 72890117c9..add4371149 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -599,6 +599,7 @@ const zh = { invalidBoolean: '"{{value}}" 不是有效的是/否值', invalidNumber: '"{{value}}" 不是有效的数字', invalidDate: '"{{value}}" 不是有效的日期', + invalidTime: '"{{value}}" 不是有效的时间', invalidOption: '"{{value}}" 不在允许的选项范围内', requiredValue: '此字段为必填项', matchAmbiguous: '匹配到多条已有记录,请使用唯一值或记录 ID', diff --git a/packages/plugin-grid/src/ImportWizard.cellCheck-11913.test.tsx b/packages/plugin-grid/src/ImportWizard.cellCheck-11913.test.tsx new file mode 100644 index 0000000000..f90de74e25 --- /dev/null +++ b/packages/plugin-grid/src/ImportWizard.cellCheck-11913.test.tsx @@ -0,0 +1,121 @@ +/** + * ObjectUI – Copyright (c) 2024-present ObjectStack Inc. + * Licensed under MIT. + */ + +/** + * The Import Wizard preview's cell check agrees with the server on a `time` + * column, and on the user import's email column (objectui#11913). The import + * button counts the rows the preview finds nothing on, so a cell the server + * refuses and the preview takes is a row the button overcounts. The server side, + * as published in 17.7.0: + * + * - a `time` cell is read by `parseDateCell(cell, 'time')` (`@objectstack/core`'s + * `import-coerce.ts`), which takes `10:00` and refuses `25:00`, `abc`, + * `10:00Z` and `9am` with `invalid_time`; + * - the user import endpoint refuses a non-ASCII or placeholder email with + * `INVALID_EMAIL` (plugin-auth's `isLikelyEmail` / `isPlaceholderEmail`). Its + * host flags the column `emailRule: 'identity'`; a plain `email` field keeps + * the record validator's rule. + */ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { ImportWizard, __testables } from './ImportWizard'; + +const { checkImportCell, formatDryRunError } = __testables; +const OFF = { keepUnknownOptions: false, requireValues: true }; +type WizardField = React.ComponentProps['fields'][number]; +const field = (type: string, extra: Partial = {}): WizardField => ({ name: 'f', label: 'F', type, ...extra }); +const IDENTITY_EMAIL = field('email', { emailRule: 'identity' }); + +/** English rendering with `{{var}}` interpolation, standing in for `t`. */ +const t = (key: string, vars?: Record) => + key === 'grid.import.invalidTime' ? `"${String(vars?.value)}" is not a valid time` : key; + +describe('checkImportCell: a time cell read the way the server reads it (objectui#11913)', () => { + it('takes a wall clock', () => { + expect(checkImportCell('10:00', field('time'), OFF)).toBeUndefined(); + }); + + it('marks what the server refuses with invalid_time', () => { + for (const cell of ['25:00', 'abc', '10:00Z', '9am']) { + expect(checkImportCell(cell, field('time'), OFF), cell).toEqual({ code: 'invalid_time', value: cell }); + } + }); + + it('reads the server dry run\'s invalid_time with the same sentence', () => { + const { message } = formatDryRunError({ field: 'f', code: 'invalid_time', error: 'f: "9am" is not a valid time' }, new Map(), '9am', t); + expect(message).toBe('"9am" is not a valid time'); + }); +}); + +describe('checkImportCell: an identity email column by the endpoint rule (objectui#11913)', () => { + it('marks a non-ASCII address and a placeholder address', () => { + for (const cell of ['735431496@柴仟.com', 'u-abc@placeholder.invalid']) { + expect(checkImportCell(cell, IDENTITY_EMAIL, OFF), cell).toEqual({ code: 'invalid_email', value: cell }); + } + }); + + it('takes a plain address', () => { + expect(checkImportCell('ada@example.com', IDENTITY_EMAIL, OFF)).toBeUndefined(); + }); + + it('asks the endpoint rule instead of the record rule, not both', () => { + // The endpoint takes `a@b..c`; the record validator refuses it. + expect(checkImportCell('a@b..c', IDENTITY_EMAIL, OFF)).toBeUndefined(); + expect(checkImportCell('a@b..c', field('email'), OFF)).toEqual({ code: 'invalid_email', value: 'a@b..c' }); + }); + + it('leaves a plain email field on the record rule', () => { + expect(checkImportCell('735431496@柴仟.com', field('email'), OFF)).toBeUndefined(); + }); +}); + +/** 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); }); +} + +/** Open the wizard, paste rows whose headers equal the field labels, go to the preview. */ +async function openPreview(tsv: string, fields: WizardField[]) { + render( {}} />); + pasteRows(tsv); + const next = await screen.findByTestId('import-next-btn'); + await waitFor(() => expect(next).toBeEnabled()); + fireEvent.click(next); + return screen.findByTestId('import-run-btn'); +} + +const cell = (row: number, col: number) => screen.getByTestId(`import-preview-cell-${row}-${col}`); +const TITLE: WizardField = { name: 'title', label: 'Title', type: 'text' }; + +describe('ImportWizard preview: the import button follows the server verdict (objectui#11913)', () => { + it('marks the time cells the server refuses and counts only the clean row', async () => { + const button = await openPreview( + 'Title\tStarts\nA\t10:00\nB\t25:00\nC\tabc\nD\t10:00Z\nE\t9am', + [TITLE, { name: 'starts', label: 'Starts', type: 'time' }], + ); + expect(cell(0, 1)).toHaveAttribute('aria-invalid', 'false'); + for (const row of [1, 2, 3, 4]) expect(cell(row, 1)).toHaveAttribute('aria-invalid', 'true'); + expect(cell(1, 1).closest('td')).toHaveAttribute('title', '"25:00" is not a valid time'); + expect(button).toHaveTextContent('Import 1 Row'); + }); + + it('marks a non-ASCII and a placeholder address on an identity email column', async () => { + const button = await openPreview( + 'Title\tEmail\nA\tada@example.com\nB\t735431496@柴仟.com\nC\tu-abc@placeholder.invalid', + [TITLE, { name: 'email', label: 'Email', type: 'email', emailRule: 'identity' }], + ); + expect(cell(0, 1)).toHaveAttribute('aria-invalid', 'false'); + expect(cell(1, 1)).toHaveAttribute('aria-invalid', 'true'); + expect(cell(2, 1)).toHaveAttribute('aria-invalid', 'true'); + expect(button).toHaveTextContent('Import 1 Row'); + }); +}); diff --git a/packages/plugin-grid/src/ImportWizard.tsx b/packages/plugin-grid/src/ImportWizard.tsx index dfb5fea501..b06895dfe3 100644 --- a/packages/plugin-grid/src/ImportWizard.tsx +++ b/packages/plugin-grid/src/ImportWizard.tsx @@ -19,7 +19,8 @@ import { BOOLEAN_VALUE_TYPES, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES, SINGLE_OPTION_TYPES, } from '@objectstack/spec/data'; import { - BOOLEAN_IMPORT_TOKENS, REFERENCE_IMPORT_TYPES, isImportableDateCell, isRecordEmail, + BOOLEAN_IMPORT_TOKENS, REFERENCE_IMPORT_TYPES, isIdentityEmail, isImportableDateCell, isImportableTimeCell, + isRecordEmail, } from './importCoercionContract'; import type { DataSource, @@ -173,6 +174,7 @@ export const IMPORT_DEFAULT_TRANSLATIONS: Record = { 'grid.import.invalidBoolean': '"{{value}}" is not a valid true/false value', 'grid.import.invalidNumber': '"{{value}}" is not a valid number', 'grid.import.invalidDate': '"{{value}}" is not a valid date', + 'grid.import.invalidTime': '"{{value}}" is not a valid time', 'grid.import.invalidOption': '"{{value}}" is not one of the allowed options', 'grid.import.requiredValue': 'This field is required', 'grid.import.matchAmbiguous': 'Matches more than one existing record — use a unique value or the record id', @@ -361,6 +363,13 @@ export interface ImportWizardProps { * engine strips readonly values from updates and respects explicitly * seeded values on insert. */ matchOnly?: boolean; + /** Which rule the preview checks an `email` column's cells by. Left out, + * it is the record validator's rule, the one the generic import meets. + * `'identity'` is the user import endpoint's own rule (printable ASCII, + * at most 254 characters, no placeholder address), for a host + * that writes the column through that endpoint, as the identity import + * does. Only an `email` field reads it. */ + emailRule?: 'identity'; }>; dataSource: any; onComplete?: (result: ImportResult) => void; @@ -493,6 +502,8 @@ function formatDryRunError( return { fieldLabel, message: t('grid.import.invalidNumber', { value: shown }) }; case 'invalid_date': return { fieldLabel, message: t('grid.import.invalidDate', { value: shown }) }; + case 'invalid_time': + return { fieldLabel, message: t('grid.import.invalidTime', { value: shown }) }; case 'invalid_option': return { fieldLabel, message: t('grid.import.invalidOption', { value: shown }) }; case 'required': @@ -616,7 +627,7 @@ function refusedOptionValue( type CellRefusal = | { code: 'required' } | { - code: 'invalid_number' | 'invalid_boolean' | 'invalid_date' | 'invalid_email' | 'invalid_option'; + code: 'invalid_number' | 'invalid_boolean' | 'invalid_date' | 'invalid_time' | 'invalid_email' | 'invalid_option'; /** The refused value: the trimmed cell, or the refused token of a multi-value cell. */ value: string; }; @@ -643,6 +654,9 @@ interface CellCheckOptions { * `number` (objectui#11889). A `date` / `datetime` cell is read by the server's * grammar and supported years, and an `email` cell by the record validator's * rule, which takes a non-ASCII address (both in `importCoercionContract.ts`). + * A `time` cell is read the way the server reads a time of day, and an `email` + * column flagged `emailRule: 'identity'` by the user import endpoint's rule + * instead of the record validator's (objectui#11913). */ function checkImportCell( cell: string, @@ -661,7 +675,11 @@ function checkImportCell( if (type === 'date' || type === 'datetime') { return isImportableDateCell(value, type) ? undefined : { code: 'invalid_date', value }; } - if (type === 'email') return isRecordEmail(value) ? undefined : { code: 'invalid_email', value }; + if (type === 'time') return isImportableTimeCell(value) ? undefined : { code: 'invalid_time', value }; + if (type === 'email') { + const takes = field.emailRule === 'identity' ? isIdentityEmail(value) : isRecordEmail(value); + return takes ? undefined : { code: 'invalid_email', value }; + } const refused = refusedOptionValue(cell, field, opts.keepUnknownOptions); return refused === undefined ? undefined : { code: 'invalid_option', value: refused }; } @@ -677,6 +695,7 @@ function cellRefusalMessage( case 'invalid_number': return t('grid.import.invalidNumber', { value: refusal.value }); case 'invalid_boolean': return t('grid.import.invalidBoolean', { value: refusal.value }); case 'invalid_date': return t('grid.import.invalidDate', { value: refusal.value }); + case 'invalid_time': return t('grid.import.invalidTime', { value: refusal.value }); case 'invalid_option': return t('grid.import.invalidOption', { value: refusal.value }); case 'invalid_email': return t('grid.import.invalidType', { type: field.type }); } diff --git a/packages/plugin-grid/src/importCoercionContract.test.ts b/packages/plugin-grid/src/importCoercionContract.test.ts index 1dbcf5c243..71e161572c 100644 --- a/packages/plugin-grid/src/importCoercionContract.test.ts +++ b/packages/plugin-grid/src/importCoercionContract.test.ts @@ -26,7 +26,10 @@ import { BOOLEAN_TRUE_IMPORT_TOKENS, IMPORT_TEMPORAL_YEARS, REFERENCE_IMPORT_TYPES, + isIdentityEmail, isImportableDateCell, + isImportableTimeCell, + isRecordEmail, } from './importCoercionContract'; describe('BOOLEAN_IMPORT_TOKENS (server BOOL_TRUE/BOOL_FALSE mirror)', () => { @@ -156,3 +159,80 @@ describe('isImportableDateCell (server parseDateCell mirror, objectui#11889)', ( } }); }); + +describe('isImportableTimeCell (server parseDateCell time mirror, objectui#11913)', () => { + // `parseDateCell(cell, 'time')` in `@objectstack/core`'s `import-coerce.ts` + // asks `readTimeOfDayCell` (core's comparand rule: the spec's + // `ClockTimeValueSchema`, or an ISO day or instant), then `readYearFirstCell`. + // Core is not a dependency of this package, so these inventories are the + // tripwire. Each verdict below was read from core 17.7.0's `parseDateCell`. + // If one fails you changed what the preview takes: check that function takes + // the same cells before re-pinning, or the import button miscounts. + + it('takes a wall clock, an ISO day or instant, and the year-first form', () => { + const taken = [ + '10:00', ' 10:00 ', '00:00', '23:59', '09:30:15', '23:59:59.5', '10:00:00.123456', '2026-07-15', '0000-06-15', + '2026-07-15T10:00', '2026-07-15T10:00:00Z', '2026-07-15T10:00+08:00', '2026-07-15T10:00+0800', '2026-07-15 10:00', + '2026-07-15 24:00', '2026-07-15T24:00Z', '2026/7/15', '2026-7-15 9:00', '2026/08/01 06:00:00', + ]; + for (const cell of taken) expect(isImportableTimeCell(cell), cell).toBe(true); + }); + + it('refuses the card cells, a clock the spec does not read, and an instant with no four-digit UTC year', () => { + const refused = [ + '25:00', 'abc', '10:00Z', '9am', '9:00', '24:00', '23:60', '10:00+08:00', '10:00 AM', '10.00', '1000', '{now}', + '2026-07-15 10:00Z', '2026/7/15 24:00', '2026/7/15T9:00', '2026-02-30', '07/15/2026', '2026-07-15T24:01Z', + '9999-12-31T24:00Z', '+010000-01-01T10:00:00Z', '0000-01-01T00:30:00+01:00', + ]; + for (const cell of refused) expect(isImportableTimeCell(cell), cell).toBe(false); + }); + + it('reads an ISO instant exactly where the V8 Date.parse the server runs on reads one', () => { + // A `T` cell with a zone is parsed as written, and one without a zone with + // a `Z` appended (core's `readsAsInstant`). Its time survives only when the + // instant's UTC year has four digits (`keepsTimeOfDay`). + const clocks = ['00:00', '10:00:30.250', '23:59:59', '23:60', '24:00', '24:00:00.001', '25:00']; + const zones = ['', 'Z', '+08:00', '+0800', '-01:00', '+24:00']; + for (const day of ['2026-07-15', '0000-01-01', '9999-12-31']) { + for (const clock of clocks) { + for (const zone of zones) { + const cell = `${day}T${clock}${zone}`; + const ms = Date.parse(zone === '' ? `${cell}Z` : cell); + const year = Number.isNaN(ms) ? undefined : new Date(ms).getUTCFullYear(); + expect(isImportableTimeCell(cell), cell).toBe(year !== undefined && year >= 0 && year <= 9999); + } + } + } + }); +}); + +describe('isIdentityEmail (user import endpoint mirror, objectui#11913)', () => { + // `resolveRowIdentity` in plugin-auth's `admin-import-users.ts` refuses a row + // with `INVALID_EMAIL` unless `isLikelyEmail` takes its trimmed email cell, + // and when `isPlaceholderEmail` does. Neither is published to this package, + // so this inventory is the tripwire. If it fails, check the 17.7.0 functions + // take the same cells before re-pinning. + + it('takes a plain ASCII address', () => { + for (const email of ['ada@example.com', 'a@b.co', 'first.last+tag@sub.example.org', `${'a'.repeat(249)}@x.co`]) { + expect(isIdentityEmail(email), email).toBe(true); + } + }); + + it('refuses a non-ASCII or placeholder address, a malformed one, and one past 254 characters', () => { + const refused = [ + '735431496@柴仟.com', 'é@x.com', 'u-abc@placeholder.invalid', 'U-ABC@Placeholder.Invalid', + 'a@b', 'a@b.', 'a@.b', '@b.c', 'a@', 'a@@b.c', 'a b@c.d', `${'a'.repeat(250)}@x.co`, + ]; + for (const email of refused) expect(isIdentityEmail(email), email).toBe(false); + }); + + it('is not a stricter record rule: each takes an address the other refuses', () => { + // So the preview reads an identity email column by this rule INSTEAD of + // `isRecordEmail`; asking both would mark `a@b..c`, which the endpoint takes. + expect(isIdentityEmail('a@b..c')).toBe(true); + expect(isRecordEmail('a@b..c')).toBe(false); + expect(isIdentityEmail('735431496@柴仟.com')).toBe(false); + expect(isRecordEmail('735431496@柴仟.com')).toBe(true); + }); +}); diff --git a/packages/plugin-grid/src/importCoercionContract.ts b/packages/plugin-grid/src/importCoercionContract.ts index bfd9ba4762..5383302873 100644 --- a/packages/plugin-grid/src/importCoercionContract.ts +++ b/packages/plugin-grid/src/importCoercionContract.ts @@ -13,10 +13,12 @@ * and never flagged for a value the server would take (objectui#3017). It * also carries the two checks the import meets after coercion, at the engine's * write door, that the preview repeats: a `date` / `datetime` value's - * supported years and an `email` value's shape (objectui#11889). + * supported years and an `email` value's shape (objectui#11889). A `time` + * cell's reading and the user import's stricter email rule joined them in + * objectui#11913. */ -import { REFERENCE_VALUE_TYPES } from '@objectstack/spec/data'; +import { ClockTimeValueSchema, REFERENCE_VALUE_TYPES } from '@objectstack/spec/data'; /** * Truthy tokens the server's boolean coercion accepts (`BOOL_TRUE`), compared @@ -165,6 +167,15 @@ function importDateCellYear(s: string): number | undefined { } } } + return yearFirstCellYear(s); +} + +/** + * The year of a trimmed cell the server's `readYearFirstCell` reads, or + * `undefined` where it reads none: a real calendar day and, when the cell + * carries one, a wall clock in range. + */ +function yearFirstCellYear(s: string): number | undefined { const yearFirst = YEAR_FIRST_CELL.exec(s); if (!yearFirst) return undefined; const [, y, , mo, d, hh, mi, ss] = yearFirst; @@ -192,6 +203,59 @@ export function isImportableDateCell(cell: string, kind: 'date' | 'datetime'): b return year >= first && year <= last; } +// ── times (objectui#11913) ───────────────────────────────────────────────── +// +// The server reads a `time` cell with the same `parseDateCell`, asking it for a +// time of day: `readTimeOfDayCell` first, then `readYearFirstCell` alone, never +// `readIsoTemporalCell`. `readTimeOfDayCell` asks core's comparand rule +// (`isUninterpretableTemporalComparand`), the rule the engine's write door +// judges a `time` value by too, so a time of day it stores is never refused +// after coercion. That rule takes a wall clock the spec's `ClockTimeValueSchema` +// takes, which is imported rather than restated, because the spec publishes it +// and the server reads that very schema. It also takes an instant in an ISO +// 8601 spelling. It refuses a `{placeholder}` filter token, and none of the +// three readings below can match one. + +/** + * Whether core's `readsAsInstant` and `keepsTimeOfDay` both take this trimmed + * cell: an ISO 8601 day or date-time (the `T` form with or without a zone, the + * space form without one) on a real calendar day, whose instant has a four-digit + * UTC year, because the time of day is read back out of `toISOString`. A + * zone-naive clock is read as UTC there, and `24:00` as the next day's midnight, + * the way V8's `Date.parse` reads it. + */ +function isoCellKeepsTimeOfDay(s: string): boolean { + const iso = ISO_TEMPORAL_CELL.exec(s); + if (!iso) return false; + const [, y, mo, d, sep, hh, mi, ss, frac, zone] = iso; + const year = Number(y); + if (!namesRealCalendarDay(year, Number(mo), Number(d))) return false; + if (sep === undefined) return true; + if (sep === ' ' && zone !== undefined) return false; + const utcYear = zonedCellUtcYear({ + year, month: Number(mo), day: Number(d), + hour: Number(hh), minute: Number(mi), second: ss ? Number(ss) : 0, fraction: frac ?? '', + }, zone ?? 'Z'); + return utcYear !== undefined && utcYear >= 0 && utcYear <= 9999; +} + +/** + * Whether the server's import takes this `time` cell: its `parseDateCell` reads + * a time of day from it, or the import refuses it with `invalid_time`. Accept or + * refuse only; the preview never stores the value. No supported years apply, + * because the engine checks none for a `time`. + * + * Taken: `10:00`, `09:30:15`, `23:59:59.5`, `2026-07-15T10:00:00Z`, + * `2026-07-15 10:00`, `2026-07-15`, `2026/7/15 9:00`. Refused: `25:00`, `abc`, + * `10:00Z`, `9am`, `9:00`, `24:00`, `2026-07-15 10:00Z`, `2026/7/15 24:00`. + */ +export function isImportableTimeCell(cell: string): boolean { + const s = cell.trim(); + return ClockTimeValueSchema.safeParse(s).success + || isoCellKeepsTimeOfDay(s) + || yearFirstCellYear(s) !== undefined; +} + // ── email (objectui#11889) ───────────────────────────────────────────────── /** @@ -206,10 +270,10 @@ export function isImportableDateCell(cell: string, kind: 'date' | 'datetime'): b * backtrack whatever the input. The pattern itself is the oracle the paired * test compares this against. * - * The stricter identity rule (`isLikelyEmail`: printable ASCII, at most 254 - * characters, framework#3566) belongs to the user import, whose endpoint applies - * it in its dry run; the user import's email column reaches the wizard typed - * `text`, so this check never runs there. + * The user import's email column is not read by this rule. Its endpoint applies + * its own, {@link isIdentityEmail}, and the column reaches the wizard flagged + * `emailRule: 'identity'`, so the preview asks that rule there instead of this + * one (objectui#11913). */ export function isRecordEmail(value: string): boolean { if (/\s/.test(value)) return false; @@ -218,3 +282,39 @@ export function isRecordEmail(value: string): boolean { const labels = value.slice(at + 1).split('.'); return labels.length >= 2 && labels.every((label) => label.length > 0); } + +/** + * The domain plugin-auth mints a placeholder address under for a user with no + * real email (`PLACEHOLDER_EMAIL_DOMAIN`). It is an RFC 2606 reserved name, so + * no address on it can be delivered. + */ +const PLACEHOLDER_EMAIL_DOMAIN = 'placeholder.invalid'; + +/** + * Whether the user import's endpoint (`/api/v1/auth/admin/import-users`) takes + * this trimmed email cell. Its `resolveRowIdentity` (plugin-auth's + * `admin-import-users.ts`) refuses the row with `INVALID_EMAIL`, in its dry run + * too, in two cases: + * + * - `isLikelyEmail` refuses the cell. It takes at most 254 characters of + * printable ASCII with no whitespace, one `@` that is neither first nor last, + * and a domain whose last dot is neither its first nor its last character + * (framework#3566). + * - `isPlaceholderEmail` takes it: an address on {@link PLACEHOLDER_EMAIL_DOMAIN}, + * in any case. + * + * This is not a stricter {@link isRecordEmail}. It takes `a@b..c`, which the + * record validator refuses, and refuses `735431496@柴仟.com`, which that one + * takes. So the preview reads an identity email column by this rule instead of + * the record rule, and never by both. + */ +export function isIdentityEmail(value: string): boolean { + if (value.length === 0 || value.length > 254 || /\s/.test(value)) return false; + if (/[^\x20-\x7e]/.test(value)) return false; + const at = value.indexOf('@'); + if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return false; + const domain = value.slice(at + 1); + const dot = domain.lastIndexOf('.'); + if (!(dot > 0 && dot < domain.length - 1)) return false; + return !value.toLowerCase().endsWith(`@${PLACEHOLDER_EMAIL_DOMAIN}`); +} From a060123d19a92aea502f7ede51eb343e3694c086 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:52:18 +0000 Subject: [PATCH 2/3] test(i18n): the de quote census counts grid.import.invalidTime's pair The de pack's new `grid.import.invalidTime` row quotes the cell with one matched pair, so the pinned span and pair counts move from 75 to 76. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../i18n/src/__tests__/de-quote-pairing-3876.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index b74c05049a..8845603ad9 100644 --- a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -313,7 +313,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // `.list`, the filter builder's note on how its two empty checks differ, // which each quote both operator labels („{{isEmpty}}“ / „{{isNull}}“) — // four interpolated spans, the dropdown's own labels. - expect(okSpans, 'correctly paired spans').toBe(75); + // 76 once objectui#11913 added `grid.import.invalidTime`, the import + // preview's refusal of a time cell, which quotes the cell („{{value}}“) — + // one interpolated span, runtime data. + expect(okSpans, 'correctly paired spans').toBe(76); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -354,8 +357,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // `_other` carry one matched pair each and `resultsCountPlural` left. // 71 / 71 / 0 after objectui#11695 added `objectActions.deleteConfirmTitle`, // one more matched pair. 75 / 75 / 0 after objectui#11810 added the two - // `filterBuilder.emptyCheckHint` values, two matched pairs each. - expect({ open, close, rdq }).toEqual({ open: 75, close: 75, rdq: 0 }); + // `filterBuilder.emptyCheckHint` values, two matched pairs each. 76 / 76 / 0 + // after objectui#11913 added `grid.import.invalidTime`, one more matched pair. + expect({ open, close, rdq }).toEqual({ open: 76, close: 76, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); From 34ade8bde01b05cd861c6706b8839fbeeca05f7c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:01:32 +0000 Subject: [PATCH 3/3] fix(app-shell): type the identity email entry against the wizard's field type `ObjectView` passes `identityImportFields` or `importTargetFields` into the wizard's `fields` through one conditional, which compiles only while this return type is a supertype of `ImportTargetField`. The email entry is typed at its literal against `ImportWizardProps['fields'][number]`, so `emailRule` is still checked where it is written. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../app-shell/src/views/IdentityImportPanels.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/app-shell/src/views/IdentityImportPanels.tsx b/packages/app-shell/src/views/IdentityImportPanels.tsx index 4e6839241f..731fd830cb 100644 --- a/packages/app-shell/src/views/IdentityImportPanels.tsx +++ b/packages/app-shell/src/views/IdentityImportPanels.tsx @@ -133,13 +133,22 @@ export function IdentityImportResultExtra({ serverResult }: { serverResult?: Imp * The email column is typed `email` and flagged `emailRule: 'identity'`, so * the wizard's preview checks it by the identity endpoint's rule: a non-ASCII * or placeholder address is marked, as the endpoint refuses it with - * `INVALID_EMAIL` (objectui#11913). */ + * `INVALID_EMAIL` (objectui#11913). + * + * The flag is typed where it is written, against the wizard's own field type, + * and the return type does not name it. `ObjectView` passes this list or + * `importTargetFields`' list into the wizard's `fields` through one + * conditional, and that expression compiles only while this return type is a + * supertype of `ImportTargetField`, which declares no `emailRule`. */ export function identityImportFields( objectFields: Record | undefined, -): ImportWizardProps['fields'] { +): Array<{ name: string; label: string; type: string; required?: boolean }> { const label = (name: string, fallback: string) => objectFields?.[name]?.label || fallback; + const email: ImportWizardProps['fields'][number] = { + name: 'email', label: label('email', 'Email'), type: 'email', emailRule: 'identity', + }; return [ - { name: 'email', label: label('email', 'Email'), type: 'email', emailRule: 'identity' }, + email, { name: 'phone_number', label: label('phone_number', 'Phone Number'), type: 'text' }, { name: 'name', label: label('name', 'Name'), type: 'text' }, { name: 'role', label: label('role', 'Platform Role'), type: 'text' },