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
15 changes: 15 additions & 0 deletions .changeset/11913-import-time-identity-email.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 17 additions & 2 deletions packages/app-shell/src/views/IdentityImportPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -127,13 +128,27 @@ 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).
*
* 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<string, any> | undefined,
): 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: 'text' },
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' },
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<ImportWizard
objectName="sys_user"
fields={identityImportFields(undefined)}
dataSource={{}}
open
onOpenChange={() => {}}
/>,
);
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');
});
});
10 changes: 7 additions & 3 deletions packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(“)', () => {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@ const ar = {
invalidBoolean: "«{{value}}» ليست قيمة صواب/خطأ صالحة",
invalidNumber: "«{{value}}» ليست رقمًا صالحًا",
invalidDate: "«{{value}}» ليست تاريخًا صالحًا",
invalidTime: "«{{value}}» ليست وقتًا صالحًا",
invalidOption: "«{{value}}» ليست ضمن الخيارات المسموح بها",
requiredValue: "هذا الحقل مطلوب",
matchAmbiguous: "يطابق أكثر من سجل موجود — استخدم قيمة فريدة أو معرّف السجل",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ const ja = {
invalidBoolean: "「{{value}}」は有効な true/false の値ではありません",
invalidNumber: "「{{value}}」は有効な数値ではありません",
invalidDate: "「{{value}}」は有効な日付ではありません",
invalidTime: "「{{value}}」は有効な時刻ではありません",
invalidOption: "「{{value}}」は許可された選択肢に含まれていません",
requiredValue: "この項目は必須です",
matchAmbiguous: "既存の複数レコードに一致します — 一意の値かレコード ID を使用してください",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ const ko = {
invalidBoolean: "\"{{value}}\"은(는) 유효한 true/false 값이 아닙니다",
invalidNumber: "\"{{value}}\"은(는) 유효한 숫자가 아닙니다",
invalidDate: "\"{{value}}\"은(는) 유효한 날짜가 아닙니다",
invalidTime: "\"{{value}}\"은(는) 유효한 시간이 아닙니다",
invalidOption: "\"{{value}}\"은(는) 허용된 선택지에 없습니다",
requiredValue: "필수 필드입니다",
matchAmbiguous: "기존 레코드 여러 건과 일치합니다 — 고유한 값이나 레코드 ID를 사용하세요",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,7 @@ const ru = {
invalidBoolean: "«{{value}}» не является допустимым значением true/false",
invalidNumber: "«{{value}}» не является допустимым числом",
invalidDate: "«{{value}}» не является допустимой датой",
invalidTime: "«{{value}}» не является допустимым временем",
invalidOption: "«{{value}}» не входит в число допустимых вариантов",
requiredValue: "Это поле обязательно",
matchAmbiguous: "Соответствует нескольким существующим записям — используйте уникальное значение или идентификатор записи",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ const zh = {
invalidBoolean: '"{{value}}" 不是有效的是/否值',
invalidNumber: '"{{value}}" 不是有效的数字',
invalidDate: '"{{value}}" 不是有效的日期',
invalidTime: '"{{value}}" 不是有效的时间',
invalidOption: '"{{value}}" 不在允许的选项范围内',
requiredValue: '此字段为必填项',
matchAmbiguous: '匹配到多条已有记录,请使用唯一值或记录 ID',
Expand Down
Loading
Loading