diff --git a/.changeset/12985-import-optouts.md b/.changeset/12985-import-optouts.md new file mode 100644 index 0000000000..9bd5c2bffc --- /dev/null +++ b/.changeset/12985-import-optouts.md @@ -0,0 +1,11 @@ +--- +'@object-ui/plugin-grid': patch +'@object-ui/i18n': patch +--- + +The Import Wizard no longer offers "Run automations & triggers" or "Import as historical data" (objectui#12985). Both import doors (`POST /api/v1/data/:object/import` and `/import/jobs`) honour `runAutomations: false` and `treatAsHistorical: true` for the system principal only and refuse every signed-in caller `403 PERMISSION_DENIED` (objectstack-ai/objectstack#24348). A user who unticked the first box or ticked the second had the whole import refused. + +- **The two options are gone** from the wizard's options panel, with their state. The wizard's import, dry-run and background-job requests carry neither `runAutomations` nor `treatAsHistorical`. Leaving both keys out is the server's declared default, so the default import is unchanged: automations run and the state machine is walked. +- **Language packs.** The three keys only those options read, `grid.import.optRunAutomations`, `grid.import.optTreatHistorical` and `grid.import.optTreatHistoricalHint`, are removed from all ten packs and from the wizard's English fallback map. The locale objects exported under `@object-ui/i18n/locales/*` lose those three members. + +Nothing else on either package entry changes: `ImportWizardProps` and `ImportResult` are unchanged, and `ImportRequestOptions` in `@object-ui/types` still declares both fields, because the server still accepts them from the system principal. diff --git a/packages/i18n/src/__tests__/makerEmptyState-v1-scope-8329.test.ts b/packages/i18n/src/__tests__/makerEmptyState-v1-scope-8329.test.ts index 4d15faf53b..c7bb3add78 100644 --- a/packages/i18n/src/__tests__/makerEmptyState-v1-scope-8329.test.ts +++ b/packages/i18n/src/__tests__/makerEmptyState-v1-scope-8329.test.ts @@ -45,7 +45,7 @@ * "automation". The platform's manual automation surfaces are real and are * deliberately NOT covered: `home.build.subtitle` (its card opens Studio, which * genuinely authors flows), `engine.studio.landing.description`, - * `dataImport.optRunAutomations`, `packagedAutomation.*`, the marketplace + * `packagedAutomation.*`, the marketplace * `automation` category, and the approvals / flow-runner groups. * * REVERT the wording (and relax this suite) when ADR-0112 v2 re-adds flows and diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 9b05070370..d912f3b1d2 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -667,9 +667,6 @@ const ar = { needMatchFields: "اختر حقلًا واحدًا على الأقل للمطابقة.", matchOnlyField: "(للمطابقة فقط)", optCreateOptions: "الاحتفاظ بقيم الخيارات غير المعروفة", - optRunAutomations: "تشغيل الأتمتة والمشغّلات", - optTreatHistorical: "الاستيراد كبيانات تاريخية", - optTreatHistoricalHint: "(استيراد السجلات المكتملة كما هي — تخطي فحوصات آلة الحالات والاحتفاظ بالطوابع الزمنية والمؤلف الأصليين بدل ختمها الآن)", optSkipBlankKey: "تخطي الصفوف التي تكون قيمة المطابقة فيها فارغة", optBackground: "الاستيراد في الخلفية", optBackgroundHint: "(يعمل كمهمة قابلة للتراجع)", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index fda12b219b..33dffc1798 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -583,9 +583,6 @@ const de = { needMatchFields: "Wählen Sie mindestens ein Feld für den Abgleich aus.", matchOnlyField: "(nur Abgleich)", optCreateOptions: "Unbekannte Optionswerte beibehalten", - optRunAutomations: "Automatisierungen und Trigger ausführen", - optTreatHistorical: "Als historische Daten importieren", - optTreatHistoricalHint: "(abgeschlossene Datensätze unverändert importieren — Zustandsautomat-Prüfungen überspringen und die ursprünglichen Zeitstempel und Autoren beibehalten, statt jetzt zu stempeln)", optSkipBlankKey: "Zeilen mit leerem Abgleichswert überspringen", optBackground: "Im Hintergrund importieren", optBackgroundHint: "(läuft als rückgängig machbarer Auftrag)", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 4c86696270..cbc9582836 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -864,9 +864,6 @@ const en = { needMatchFields: 'Select at least one field to match on.', matchOnlyField: '(match only)', optCreateOptions: 'Keep unknown option values', - optRunAutomations: 'Run automations & triggers', - optTreatHistorical: 'Import as historical data', - optTreatHistoricalHint: '(import completed records as-is — skip state-machine checks and keep their original timestamps & author instead of stamping now)', optSkipBlankKey: 'Skip rows with a blank match value', optBackground: 'Import in the background', optBackgroundHint: '(runs as an undoable job)', @@ -2365,7 +2362,7 @@ const en = { // NOT the same thing as the platform's manual automation surfaces, which // are real and stay as they are: `home.build.subtitle` (that card opens // Studio, which does author flows), `engine.studio.landing.description`, - // `dataImport.optRunAutomations`, `packagedAutomation.*` and the + // `packagedAutomation.*` and the // marketplace `automation` category. The boundary is v1 AI AUTHORING, not // the word "automation". // diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 08a5b78ad0..fb3fa66a7a 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -605,9 +605,6 @@ const es = { needMatchFields: "Seleccione al menos un campo por el que coincidir.", matchOnlyField: "(solo coincidencia)", optCreateOptions: "Conservar los valores de opción desconocidos", - optRunAutomations: "Ejecutar automatizaciones y disparadores", - optTreatHistorical: "Importar como datos históricos", - optTreatHistoricalHint: "(importa los registros completados tal cual: omite las comprobaciones de la máquina de estados y conserva sus marcas de tiempo y autor originales en lugar de sellarlos ahora)", optSkipBlankKey: "Omitir las filas con valor de coincidencia vacío", optBackground: "Importar en segundo plano", optBackgroundHint: "(se ejecuta como un trabajo reversible)", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index f010f6d982..c34134d250 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -601,9 +601,6 @@ const fr = { needMatchFields: "Sélectionnez au moins un champ de correspondance.", matchOnlyField: "(correspondance seule)", optCreateOptions: "Conserver les valeurs d'option inconnues", - optRunAutomations: "Exécuter les automatisations et déclencheurs", - optTreatHistorical: "Importer comme données historiques", - optTreatHistoricalHint: "(importer les enregistrements terminés tels quels — ignorer les contrôles de la machine à états et conserver leurs horodatages et auteurs d'origine au lieu d'horodater maintenant)", optSkipBlankKey: "Ignorer les lignes dont la valeur de correspondance est vide", optBackground: "Importer en arrière-plan", optBackgroundHint: "(s'exécute comme une tâche annulable)", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 64f3aaced7..d9b1156191 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -583,9 +583,6 @@ const ja = { needMatchFields: "照合する項目を 1 つ以上選択してください。", matchOnlyField: "(照合のみ)", optCreateOptions: "未知の選択肢の値を保持する", - optRunAutomations: "自動化とトリガーを実行する", - optTreatHistorical: "過去データとしてインポートする", - optTreatHistoricalHint: "(完了済みレコードをそのまま取り込みます — ステートマシンの検査を省略し、現在時刻を打刻せずに元のタイムスタンプと作成者を保持します)", optSkipBlankKey: "照合値が空の行をスキップする", optBackground: "バックグラウンドでインポートする", optBackgroundHint: "(取り消し可能なジョブとして実行されます)", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index d2af665973..3f5380ca65 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -583,9 +583,6 @@ const ko = { needMatchFields: "일치 기준 필드를 하나 이상 선택하세요.", matchOnlyField: "(일치 전용)", optCreateOptions: "알 수 없는 선택 값 유지", - optRunAutomations: "자동화 및 트리거 실행", - optTreatHistorical: "과거 데이터로 가져오기", - optTreatHistoricalHint: "(완료된 레코드를 그대로 가져옵니다 — 상태 머신 검사를 건너뛰고 현재 시각을 찍는 대신 원래 타임스탬프와 작성자를 유지합니다)", optSkipBlankKey: "일치 값이 비어 있는 행 건너뛰기", optBackground: "백그라운드에서 가져오기", optBackgroundHint: "(되돌릴 수 있는 작업으로 실행됩니다)", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 63b513adb4..bb2b54a833 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -600,9 +600,6 @@ const pt = { needMatchFields: "Selecione pelo menos um campo de correspondência.", matchOnlyField: "(somente correspondência)", optCreateOptions: "Manter valores de opção desconhecidos", - optRunAutomations: "Executar automações e gatilhos", - optTreatHistorical: "Importar como dados históricos", - optTreatHistoricalHint: "(importa registros concluídos como estão — ignora as verificações da máquina de estados e mantém os carimbos de data/hora e o autor originais em vez de carimbar agora)", optSkipBlankKey: "Ignorar linhas com valor de correspondência em branco", optBackground: "Importar em segundo plano", optBackgroundHint: "(executa como um trabalho reversível)", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index c482525694..e3532499e0 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -627,9 +627,6 @@ const ru = { needMatchFields: "Выберите хотя бы одно поле для сопоставления.", matchOnlyField: "(только сопоставление)", optCreateOptions: "Сохранять неизвестные значения списка", - optRunAutomations: "Выполнять автоматизации и триггеры", - optTreatHistorical: "Импортировать как исторические данные", - optTreatHistoricalHint: "(импортирует завершённые записи как есть — пропускает проверки конечного автомата и сохраняет исходные отметки времени и автора вместо текущих)", optSkipBlankKey: "Пропускать строки с пустым значением сопоставления", optBackground: "Импортировать в фоновом режиме", optBackgroundHint: "(выполняется как отменяемая задача)", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index b4847cce18..b224c32940 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -592,8 +592,6 @@ const zh = { importNRows_other: '导入 {{count}} 行', importingProgress: '导入中…', requiredMark: '*', - optTreatHistorical: '作为历史数据导入', - optTreatHistoricalHint: '(按原样导入已完结的记录——跳过状态机校验,保留原始时间戳与作者,而不是记为当前时间)', missingRequiredHint: '无法继续——以下必填字段未映射:{{fields}}。请在文件中添加对应的列,或返回上一步重新上传包含该列的文件。', legacyFallbackNotice: '已通过兼容模式导入:当前连接不支持服务端导入接口,因此所有值均以文本写入,未做服务端类型转换。请升级后端/客户端以获得完整的导入支持(类型转换与关联查找)。', required: '必填', @@ -648,7 +646,6 @@ const zh = { matchFieldsHint: '按这些字段将行匹配到已有记录。', needMatchFields: '请至少选择一个匹配字段。', matchOnlyField: '(仅匹配)', - optRunAutomations: '运行自动化与触发器', optCreateOptions: '保留未知选项值', optSkipBlankKey: '跳过匹配值为空的行', optBackground: '后台导入', diff --git a/packages/plugin-grid/src/ImportWizard.controlOptOuts-12985.test.tsx b/packages/plugin-grid/src/ImportWizard.controlOptOuts-12985.test.tsx new file mode 100644 index 0000000000..f53e923f55 --- /dev/null +++ b/packages/plugin-grid/src/ImportWizard.controlOptOuts-12985.test.tsx @@ -0,0 +1,106 @@ +/** + * ObjectUI – Copyright (c) 2024-present ObjectStack Inc. + * Licensed under MIT. + */ + +/** + * The Import Wizard offers neither control opt-out (objectui#12985). + * + * Both import doors (`POST /api/v1/data/:object/import` and `/import/jobs`) + * honour `runAutomations: false` and `treatAsHistorical: true` for the system + * principal only, and refuse every other caller `403 PERMISSION_DENIED` + * (objectstack-ai/objectstack#24348). A browser request is never the system + * principal, so a wizard that offered either option turned a user's click into + * a refusal of the whole import. The wizard therefore draws neither option and + * its request carries neither opt-out. The default import, which runs + * automations and walks the state machine, is unchanged: omitting both keys is + * the server's declared default. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { ImportWizard } from './ImportWizard'; +import type { ImportRecordsResult, ImportRequestOptions } from '@object-ui/types'; + +type WizardField = React.ComponentProps['fields'][number]; + +const FIELDS: WizardField[] = [{ name: 'title', label: 'Title', type: 'text' }]; + +/** 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 over `dataSource`, paste two rows, go to the preview step. */ +async function openPreview(dataSource: object, onComplete?: (r: unknown) => void) { + render( + {}} + onComplete={onComplete as never} + />, + ); + pasteRows('Title\nA\nB'); + const next = await screen.findByTestId('import-next-btn'); + await waitFor(() => expect(next).toBeEnabled()); + fireEvent.click(next); + return screen.findByTestId('import-run-btn'); +} + +describe('ImportWizard control opt-outs (objectui#12985)', () => { + it('draws neither the run-automations nor the treat-as-historical option', async () => { + await openPreview({}); + // CONTROL: the options panel and its other options are drawn. + expect(screen.getByTestId('import-options')).toBeInTheDocument(); + expect(screen.getByTestId('import-opt-create-options')).toBeInTheDocument(); + + expect(screen.queryByTestId('import-opt-run-automations')).toBeNull(); + expect(screen.queryByTestId('import-opt-treat-historical')).toBeNull(); + expect(screen.queryByText(/automations/i)).toBeNull(); + expect(screen.queryByText(/historical/i)).toBeNull(); + }); + + it('a completed default import sends neither opt-out and still imports', async () => { + const sent: ImportRequestOptions[] = []; + const importRecords = vi.fn(async (_object: string, request: ImportRequestOptions): Promise => { + sent.push(request); + return { + object: 'task', dryRun: false, writeMode: 'insert', total: 2, + ok: 2, errors: 0, created: 2, updated: 0, skipped: 0, + results: [{ row: 1, ok: true, action: 'created' }, { row: 2, ok: true, action: 'created' }], + } as ImportRecordsResult; + }); + const onComplete = vi.fn(); + const run = await openPreview({ importRecords }, onComplete); + await waitFor(() => expect(run).toBeEnabled()); + fireEvent.click(run); + await waitFor(() => expect(onComplete).toHaveBeenCalled()); + + // The real import request (a dry-run pre-check, if any, carries dryRun). + const real = sent.filter((r) => !r.dryRun); + expect(real).toHaveLength(1); + const request = real[0]; + expect(request).not.toHaveProperty('runAutomations'); + expect(request).not.toHaveProperty('treatAsHistorical'); + // CONTROL: the rest of the request is the default import's, unchanged. + expect(request).toEqual({ + format: 'json', + rows: [{ title: 'A' }, { title: 'B' }], + writeMode: 'insert', + createMissingOptions: false, + skipBlankMatchKey: false, + }); + + // The default import still runs to completion. + expect(onComplete.mock.calls[0][0]).toMatchObject({ totalRows: 2, importedRows: 2, skippedRows: 0, errors: [] }); + }); +}); diff --git a/packages/plugin-grid/src/ImportWizard.tsx b/packages/plugin-grid/src/ImportWizard.tsx index f91e881385..3cdd0e998b 100644 --- a/packages/plugin-grid/src/ImportWizard.tsx +++ b/packages/plugin-grid/src/ImportWizard.tsx @@ -153,9 +153,6 @@ export const IMPORT_DEFAULT_TRANSLATIONS: Record = { 'grid.import.needMatchFields': 'Select at least one field to match on.', 'grid.import.matchOnlyField': '(match only)', 'grid.import.optCreateOptions': 'Keep unknown option values', - 'grid.import.optRunAutomations': 'Run automations & triggers', - 'grid.import.optTreatHistorical': 'Import as historical data', - 'grid.import.optTreatHistoricalHint': '(import completed records as-is — skip state-machine checks and keep their original timestamps & author instead of stamping now)', 'grid.import.optSkipBlankKey': 'Skip rows with a blank match value', 'grid.import.optBackground': 'Import in the background', 'grid.import.optBackgroundHint': '(runs as an undoable job)', @@ -899,22 +896,27 @@ function transformedSourceColumns(m: SavedMapping | null, headers: string[]): Se /** Assemble the server `/import` request from mapping-applied raw rows plus the * current write-mode + coercion options. Kept pure (no component state) so the * real import and the dry-run pre-check send byte-identical payloads and it can - * be unit-tested. `matchFields` is only sent when the write-mode consults it. */ + * be unit-tested. `matchFields` is only sent when the write-mode consults it. + * + * The request never carries the two control opt-outs, `runAutomations: false` + * and `treatAsHistorical: true`: both import doors honour them for the system + * principal only and refuse every signed-in caller `403 PERMISSION_DENIED` + * (objectstack-ai/objectstack#24348), so the wizard does not offer them + * (objectui#12985). Omitting both keys is the server's declared default — + * automations run and the state machine is walked. */ function assembleImportRequest( rows: Record[], opts: { writeMode: ImportWriteMode; matchFields: string[]; createMissingOptions: boolean; - runAutomations: boolean; - treatAsHistorical?: boolean; skipBlankMatchKey: boolean; dryRun?: boolean; /** When set, the server resolves this registered mapping and owns the * rename + transform + write semantics (objectstack-ai/objectstack#2611). `rows` must * then carry SOURCE headers (see buildSourceRows), and the inline * column mapping / write-mode are omitted — mutually exclusive per the - * server contract. `runAutomations` is still honored. */ + * server contract. */ mappingName?: string; }, ): ImportRequestOptions { @@ -923,8 +925,6 @@ function assembleImportRequest( format: 'json', rows, mappingName: opts.mappingName, - runAutomations: opts.runAutomations, - ...(opts.treatAsHistorical ? { treatAsHistorical: true } : {}), ...(opts.dryRun ? { dryRun: true } : {}), }; } @@ -934,8 +934,6 @@ function assembleImportRequest( writeMode: opts.writeMode, ...(opts.writeMode !== 'insert' ? { matchFields: opts.matchFields } : {}), createMissingOptions: opts.createMissingOptions, - runAutomations: opts.runAutomations, - ...(opts.treatAsHistorical ? { treatAsHistorical: true } : {}), skipBlankMatchKey: opts.skipBlankMatchKey, ...(opts.dryRun ? { dryRun: true } : {}), }; @@ -1797,10 +1795,6 @@ const ImportOptions: React.FC<{ onToggleMatchField: (name: string) => void; createMissingOptions: boolean; onCreateMissingOptions: (v: boolean) => void; - runAutomations: boolean; - onRunAutomations: (v: boolean) => void; - treatAsHistorical: boolean; - onTreatAsHistorical: (v: boolean) => void; skipBlankMatchKey: boolean; onSkipBlankMatchKey: (v: boolean) => void; showBackground: boolean; @@ -1808,8 +1802,7 @@ const ImportOptions: React.FC<{ onBackgroundImport: (v: boolean) => void; }> = ({ fields, mapping, writeMode, onWriteMode, writeModes, matchFields, onToggleMatchField, - createMissingOptions, onCreateMissingOptions, runAutomations, onRunAutomations, - treatAsHistorical, onTreatAsHistorical, + createMissingOptions, onCreateMissingOptions, skipBlankMatchKey, onSkipBlankMatchKey, showBackground, backgroundImport, onBackgroundImport, }) => { @@ -1871,17 +1864,6 @@ const ImportOptions: React.FC<{ {t('grid.import.optSkipBlankKey')} )} - - {showBackground && (