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
9 changes: 9 additions & 0 deletions .changeset/12746-report-dataset-name-keeps-underscore.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,13 @@ async function sourceDraft(): Promise<Record<string, unknown>> {
return JSON.parse(box.value) as Record<string, unknown>;
}

// 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)', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>({ dimensions: [], measures: [] });
return (
<>
<DatasetDefaultInspector
type="dataset"
name=""
draft={draft}
onPatch={(patch) => setDraft((d) => ({ ...d, ...patch }))}
readOnly={false}
locale="en-US"
/>
<output data-testid="draft-name">{typeof draft.name === 'string' ? draft.name : '(absent)'}</output>
</>
);
}

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(<CreateHost />);
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(<CreateHost />);
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(<CreateHost />);
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(<CreateHost />);
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_');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -501,7 +501,12 @@ export function DatasetDefaultInspector({ draft, onPatch, readOnly, name, locale
<InspectorTextField
label={tr('engine.inspector.dataset.name')}
value={nameValue}
onCommit={(v) => 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>({ type: 'summary' });
return (
<>
<ReportDefaultInspector
type="report"
name=""
draft={draft}
onPatch={(patch) => setDraft((d) => ({ ...d, ...patch }))}
readOnly={false}
locale="en-US"
datasetCatalogOverride={[]}
/>
<output data-testid="draft-name">{typeof draft.name === 'string' ? draft.name : '(absent)'}</output>
</>
);
}

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(<CreateHost />);
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(<CreateHost />);
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(<CreateHost />);
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(<CreateHost />);
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_');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -480,7 +480,13 @@ export function ReportDefaultInspector({
<InspectorTextField
label={tr('engine.inspector.report.name')}
value={nameValue}
onCommit={(v) => 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
Expand Down
Loading