diff --git a/.changeset/12746-report-dataset-name-keeps-underscore.md b/.changeset/12746-report-dataset-name-keeps-underscore.md new file mode 100644 index 0000000000..09d342eadf --- /dev/null +++ b/.changeset/12746-report-dataset-name-keeps-underscore.md @@ -0,0 +1,9 @@ +--- +'@object-ui/app-shell': patch +--- + +A new report's or dataset's Name keeps every underscore you type (objectui#12746). The report and dataset designers' Name box normalized each keystroke with the strict identifier rule, which drops a trailing `_` before the next key arrives. So `qavrf_rev` typed key by key read `qavrfrev`, and a report or dataset in a namespaced package could not get its namespace prefix by typing. Clearing the box also wrote `field` back into it, so a Name could not be retyped from empty. + +Both designers now commit Name with the prefix-stable form the object designer already uses: what you type is lowercased, and runs of other characters become a single `_`, but a trailing `_` stays until the next key. A cleared box stays empty, and the Name then follows the Label again. A Name derived from the Label still uses the strict form, so a Label typed with a trailing space or punctuation never leaves a trailing `_` in the Name. The spec's identifier rule still judges the name, as before. An existing report's or dataset's name is unchanged and still not editable. + +Nothing is added to or removed from the package entry: no export, prop or type changes. diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.nameSurvivesLabel-12680.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.nameSurvivesLabel-12680.test.tsx index b5133360d2..b51c2d34c5 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.nameSurvivesLabel-12680.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.nameSurvivesLabel-12680.test.tsx @@ -141,13 +141,13 @@ async function sourceDraft(): Promise> { return JSON.parse(box.value) as Record; } +// The card's reproduction: a namespaced name, prefix included. Every designer's +// Name keeps a typed underscore (objectui#12746: the report and dataset Name +// fields used to drop it on each keystroke). const CASES: ReadonlyArray<{ type: DesignerType; typed: string }> = [ - // The card's reproduction: a namespaced name, prefix included. { type: 'object', typed: 'qavrf_hand_typed' }, - // The report and dataset Name fields normalise each keystroke with the strict - // identifier rule, so the typed value here carries no separator. - { type: 'report', typed: 'qavrfrevenue' }, - { type: 'dataset', typed: 'qavrfrevenue' }, + { type: 'report', typed: 'qavrf_hand_typed' }, + { type: 'dataset', typed: 'qavrf_hand_typed' }, ]; describe('designer create mode — a typed Name survives a later Label edit (objectui#12680)', () => { diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.nameKeepsUnderscore-12746.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.nameKeepsUnderscore-12746.test.tsx new file mode 100644 index 0000000000..3bd2352e6f --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.nameKeepsUnderscore-12746.test.tsx @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12746 — in create mode, the dataset designer's Name keeps every + * underscore the author types. + * + * The same defect, and the same repair, as the report designer's Name + * (`ReportDefaultInspector.nameKeepsUnderscore-12746.test.tsx`): the box + * commits on every keystroke and used the strict `toFieldName`, which trims a + * trailing `_` before the next key arrives. It now commits the prefix-stable + * `toFieldNameLoose`, as the object designer's Name does, and the Label → Name + * derivation keeps the strict form. + * + * The inspector is rendered with a host double that merges every patch back + * into the draft, so each keystroke sees the value the last one committed, as + * in the designer. Keystrokes are real (user-event). + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +// The catalog hooks answer empty, so the inspector renders without a +// MetadataClient or a network; the Name and Label boxes need neither. +vi.mock('./useDatasetFields', () => ({ + useObjectOptions: () => ({ options: [], loading: false }), + useDatasetFieldCatalog: () => ({ relationships: [], fieldOptions: [], loading: false }), + useDatasetUsage: () => ({ reports: 0, dashboards: 0, loading: false }), + fieldTypeToDimensionType: (t: string) => (t === 'date' ? 'date' : 'string'), +})); + +import { DatasetDefaultInspector } from './DatasetDefaultInspector'; + +afterEach(cleanup); + +/** The create-mode designer: an empty `name`, and every patch merged back. */ +function CreateHost() { + const [draft, setDraft] = React.useState>({ dimensions: [], measures: [] }); + return ( + <> + setDraft((d) => ({ ...d, ...patch }))} + readOnly={false} + locale="en-US" + /> + {typeof draft.name === 'string' ? draft.name : '(absent)'} + + ); +} + +const nameBox = () => screen.getByLabelText('Name', { exact: true }) as HTMLInputElement; +const labelBox = () => screen.getByLabelText('Label', { exact: true }) as HTMLInputElement; +const draftName = () => screen.getByTestId('draft-name').textContent; + +describe('dataset designer create mode — Name keeps what is typed (objectui#12746)', () => { + it('Name typed key by key keeps qavrf_rev, in the box and in the draft', async () => { + const user = userEvent.setup(); + render(); + await user.type(nameBox(), 'qavrf_rev'); + expect(nameBox()).toHaveValue('qavrf_rev'); + expect(draftName()).toBe('qavrf_rev'); + }); + + it('a cleared Name stays empty and can be typed again', async () => { + const user = userEvent.setup(); + render(); + await user.type(nameBox(), 'sales'); + await user.clear(nameBox()); + expect(nameBox()).toHaveValue(''); + await user.type(nameBox(), 'qavrf_rev'); + expect(nameBox()).toHaveValue('qavrf_rev'); + }); + + it('the Label → Name derivation keeps the strict form: no trailing underscore mid-edit, every separator once', async () => { + const user = userEvent.setup(); + render(); + await user.type(labelBox(), 'Revenue -- Board '); + expect(nameBox()).toHaveValue('revenue_board'); + await user.type(labelBox(), 'Q'); + expect(nameBox()).toHaveValue('revenue_board_q'); + }); + + it('a trailing underscore typed onto a derived Name makes it the author\'s: a later Label edit leaves it', async () => { + const user = userEvent.setup(); + render(); + await user.type(labelBox(), 'Qavrf'); + expect(nameBox()).toHaveValue('qavrf'); + await user.type(nameBox(), '_'); + expect(nameBox()).toHaveValue('qavrf_'); + await user.type(labelBox(), ' Board'); + expect(labelBox()).toHaveValue('Qavrf Board'); + expect(nameBox()).toHaveValue('qavrf_'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx index fdd5b26960..2810e6ea3b 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx @@ -32,7 +32,7 @@ import { spliceArray, } from './_shared.js'; import { InspectorComboField, type InspectorComboOption } from './InspectorComboField.js'; -import { toFieldName } from '../previews/object-fields-io.js'; +import { toFieldName, toFieldNameLoose } from '../previews/object-fields-io.js'; import { formatMeasure } from '@object-ui/core'; import { useDisplayLocale } from '@object-ui/i18n'; import { conditionToGroup, groupToCondition, isClearedGroup, type BuilderGroup, type FilterCondition } from './datasetFilterCondition.js'; @@ -501,7 +501,12 @@ export function DatasetDefaultInspector({ draft, onPatch, readOnly, name, locale onPatch({ name: toFieldName(v) })} + // objectui#12746 — committed on every keystroke, so the + // prefix-stable form, as ReportDefaultInspector and the object + // designer do: the strict `toFieldName` trimmed a trailing `_` + // before the next key arrived. The Label derivation below keeps + // the strict form. + onCommit={(v) => onPatch({ name: toFieldNameLoose(v) })} placeholder={tr('engine.inspector.dataset.namePlaceholder')} disabled={readOnly} mono diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.nameKeepsUnderscore-12746.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.nameKeepsUnderscore-12746.test.tsx new file mode 100644 index 0000000000..bc7e0bbeba --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.nameKeepsUnderscore-12746.test.tsx @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12746 — in create mode, the report designer's Name keeps every + * underscore the author types. + * + * The Name box commits on every keystroke. It used the strict `toFieldName`, + * which trims a trailing `_`, so the underscore was gone before the next key + * arrived: `qavrf_rev` typed key by key read `qavrfrev`, and a report in a + * namespaced package could not get its prefix by typing. It now commits the + * prefix-stable `toFieldNameLoose`, as the object designer's Name does. The + * strict form stays where the object designer keeps its own: the Label → Name + * derivation, and the comparison that decides whether the Name still follows + * the Label. + * + * The inspector is rendered with a host double that merges every patch back + * into the draft, so each keystroke sees the value the last one committed, as + * in the designer. Keystrokes are real (user-event). + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ReportDefaultInspector } from './ReportDefaultInspector'; + +afterEach(cleanup); + +// One fetch double at module scope, never torn down: the inspector's own side +// reads must not reach a socket after a test body returns. +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +/** The create-mode designer: an empty `name`, and every patch merged back. */ +function CreateHost() { + const [draft, setDraft] = React.useState>({ type: 'summary' }); + return ( + <> + setDraft((d) => ({ ...d, ...patch }))} + readOnly={false} + locale="en-US" + datasetCatalogOverride={[]} + /> + {typeof draft.name === 'string' ? draft.name : '(absent)'} + + ); +} + +const nameBox = () => screen.getByLabelText('Name', { exact: true }) as HTMLInputElement; +const labelBox = () => screen.getByLabelText('Label', { exact: true }) as HTMLInputElement; +const draftName = () => screen.getByTestId('draft-name').textContent; + +describe('report designer create mode — Name keeps what is typed (objectui#12746)', () => { + it('Name typed key by key keeps qavrf_rev, in the box and in the draft', async () => { + const user = userEvent.setup(); + render(); + await user.type(nameBox(), 'qavrf_rev'); + expect(nameBox()).toHaveValue('qavrf_rev'); + expect(draftName()).toBe('qavrf_rev'); + }); + + it('a cleared Name stays empty and can be typed again', async () => { + const user = userEvent.setup(); + render(); + await user.type(nameBox(), 'pipeline'); + await user.clear(nameBox()); + expect(nameBox()).toHaveValue(''); + await user.type(nameBox(), 'qavrf_rev'); + expect(nameBox()).toHaveValue('qavrf_rev'); + }); + + it('the Label → Name derivation keeps the strict form: no trailing underscore mid-edit, every separator once', async () => { + const user = userEvent.setup(); + render(); + await user.type(labelBox(), 'Revenue -- Board '); + expect(nameBox()).toHaveValue('revenue_board'); + await user.type(labelBox(), 'Q'); + expect(nameBox()).toHaveValue('revenue_board_q'); + }); + + it('a trailing underscore typed onto a derived Name makes it the author\'s: a later Label edit leaves it', async () => { + const user = userEvent.setup(); + render(); + await user.type(labelBox(), 'Qavrf'); + expect(nameBox()).toHaveValue('qavrf'); + await user.type(nameBox(), '_'); + expect(nameBox()).toHaveValue('qavrf_'); + await user.type(labelBox(), ' Board'); + expect(labelBox()).toHaveValue('Qavrf Board'); + expect(nameBox()).toHaveValue('qavrf_'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.tsx index 106a89971f..2b1f5e084f 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.tsx @@ -40,7 +40,7 @@ import { rosterFrom, } from './_shared.js'; import { AddFieldPopover, FieldListRow } from '../previews/ViewColumnPanes.js'; -import { toFieldName } from '../previews/object-fields-io.js'; +import { toFieldName, toFieldNameLoose } from '../previews/object-fields-io.js'; import type { MetadataDefaultInspectorProps } from '../default-inspector-registry.js'; import { SchemaForm } from '../SchemaForm.js'; import type { ObjectFieldInfo } from '../previews/useObjectFields.js'; @@ -480,7 +480,13 @@ export function ReportDefaultInspector({ onPatch({ name: toFieldName(v) })} + // objectui#12746 — this box commits on every keystroke, so it takes + // the prefix-stable form: the strict `toFieldName` trims a trailing + // `_` before the next key arrives, and `qavrf_rev` typed key by key + // read `qavrfrev`. The object designer's Name does the same. The + // strict form stays where the name is derived from the Label, and + // the spec's identifier rule judges the result. + onCommit={(v) => onPatch({ name: toFieldNameLoose(v) })} placeholder={tr('engine.inspector.report.namePlaceholder')} disabled={readOnly} mono