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
11 changes: 11 additions & 0 deletions .changeset/27833-new-package-name-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

feat(studio): New Package asks only for a name; the package ID, namespace, version and type sit behind Advanced

Clause-②: no
Metadata-protocol: none

The New Package dialog (Home → New → New Package, and Packages → New Package) used to open on a reverse-domain package ID, an object namespace, a SemVer version and a package type, all up front. Its first step now holds the display name and the optional description. The ID and namespace are still filled in from the name, and a line under the name says what they will be. The four technical fields sit in a collapsed **Advanced** section, where they stay editable; it opens by itself when one of them is what keeps **Create package** disabled, for example a name with no Latin letters to make an ID from. The dialog's description no longer explains reverse-domain IDs. Editing an existing package is unchanged.
2 changes: 2 additions & 0 deletions ui/e2e/live/studio-object-designer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ test('F1: a newly created package appears in the switcher without a reload', asy
await chip.click();
await page.getByRole('button', { name: /New package/i }).click();
await page.getByRole('dialog').getByLabel(/^Display name/).first().fill(pkgName);
// The id sits in the collapsed Advanced section (objectstack#27833).
await page.getByTestId('package-form-advanced-toggle').click();
await page.getByTestId('package-form-id-input').fill(pkgId);
await page.getByTestId('package-form-submit').click();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,25 @@
* description carries no scope word at all.
*
* The description is read off the rendered dialog through its
* `aria-describedby` link, and the interpolated `{example}` is the positive
* control that the node read is the description and not an empty one.
* `aria-describedby` link, and its catalogue sentence is the positive control
* that the node read is the description and not an empty one. (It used to be
* the interpolated example id; objectstack#27833 took the id out of the first
* step's copy.)
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { PackageFormDialog } from './PackageFormDialog';
import { t } from './i18n';
// The zh rows live in `@object-ui/i18n`'s `studio` namespace, fetched on
// demand (objectstack#25922). Resident before any case, so the dialog and the
// positive control below read the same catalogue: rendering zh otherwise
// starts the fetch, and the control could read zh while the dialog had
// rendered the en fallback.
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
await loadStudioCatalogue('zh');

let localeFixture: 'en-US' | 'zh-CN' = 'en-US';
vi.mock('./i18n', async (importOriginal) => ({
Expand All @@ -39,8 +49,8 @@ async function createDescription(): Promise<string> {
const dialog = screen.getByRole('dialog');
const ids = (dialog.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean);
const text = ids.map((id) => document.getElementById(id)?.textContent ?? '').join(' ');
// Positive control: the example id is interpolated into the description.
expect(text).toContain('com.acme.crm');
// Positive control: the node read is the description's own sentence.
expect(text).toContain(t('engine.packages.create.description', localeFixture));
return text;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
* (b) adding `@acme/core` → `^1.2.0` and saving in edit mode sends that map;
* (c) removing every row sends `{}`, which clears the row (objectui#13121);
* (d) a duplicate package name is flagged inline and nothing is sent;
* (e) CONTROL — create mode shows the same editor.
* (e) CONTROL — create mode shows the same editor (under Advanced,
* objectstack#27833).
*/

import * as React from 'react';
Expand Down Expand Up @@ -146,6 +147,9 @@ describe('PackageFormDialog — the Dependencies row is a key/value editor (obje

it('(e) CONTROL — create mode shows the same editor', async () => {
openDialog('create');
// objectstack#27833 — on create the editor sits in the collapsed
// Advanced disclosure with the other technical rows.
fireEvent.click(screen.getByTestId('package-form-advanced-toggle'));
const group = await dependencies();
expect(within(group).getByTestId('package-dependencies-empty').textContent).toBe('No dependencies.');
addRow(group, '@acme/core', '^1.2.0');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,17 @@ function openCreate() {
}

describe('New package form: display name first (objectui#12512)', () => {
it('opens on the display name, with the id, namespace and version after it in the same card', () => {
// objectstack#27833 moved the id, namespace and version out of the Basics
// card into the Advanced disclosure; the name still opens the form, and
// the three keep their order behind it.
it('opens on the display name, with the id, namespace and version after it under Advanced', () => {
const f = openCreate();
const card = screen.getByTestId('package-form-basics');
const inputs = within(card).getAllByRole('textbox');
const order = [f.name(), f.id(), f.ns(), f.version()].map((el) => inputs.indexOf(el));
expect(order).toEqual([0, 1, 2, 3]);
const basics = within(screen.getByTestId('package-form-basics')).getAllByRole('textbox');
expect(basics.indexOf(f.name())).toBe(0);
const advanced = screen.getByTestId('package-form-advanced');
const inputs = Array.from(advanced.querySelectorAll('input'));
const order = [f.id(), f.ns(), f.version()].map((el) => inputs.indexOf(el));
expect(order).toEqual([0, 1, 2]);
});

it('typing a display name fills the id, namespace and version, each marked until edited', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* New package: the first step asks only for a name (objectstack#27833).
*
* Measured on the card: Home → New → New Package opened on a reverse-domain
* id, an object namespace, a SemVer version and a package type, all up front,
* under developer help. The id, namespace and version were already filled in
* from the name (objectui#11792, objectui#12512); what was missing was a first
* step a non-developer can answer. The technical rows now sit behind one
* collapsed Advanced disclosure, and a line under the name says what was
* filled in.
*
* "Closed" is read the way a browser hides it: the disclosure's content
* carries `hidden`, so its rows leave the accessibility tree (`getByRole`
* cannot reach them) while staying mounted.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PackageFormDialog } from './PackageFormDialog';
import { t, tFormat } from './i18n';

vi.mock('./i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadataLocale: () => 'en-US',
}));

let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn(
async () => ({ ok: true, status: 201, text: async () => '{"success":true,"data":{}}' }) as Response,
);
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

const label = (key: string) => t(key, 'en-US');

function openCreate() {
render(<PackageFormDialog mode="create" open onOpenChange={vi.fn()} />);
const byLabel = (key: string) => screen.getByLabelText(label(key), { exact: false }) as HTMLInputElement;
return {
name: () => byLabel('engine.packages.create.name'),
id: () => byLabel('engine.packages.create.id'),
ns: () => byLabel('engine.packages.create.namespace'),
toggle: () => screen.getByTestId('package-form-advanced-toggle'),
content: () => screen.getByTestId('package-form-id').parentElement as HTMLElement,
submit: () => screen.getByTestId('package-form-submit'),
};
}

describe('the first step asks only for a name (objectstack#27833)', () => {
it('opens with the name and the description in reach, the technical rows folded away', () => {
const f = openCreate();
const basics = screen.getByTestId('package-form-basics');
expect(within(basics).getAllByRole('textbox')).toEqual([
f.name(),
screen.getByLabelText(label('engine.packages.detail.description'), { exact: false }),
]);

expect(f.toggle()).toHaveAttribute('aria-expanded', 'false');
expect(f.content()).toHaveAttribute('hidden');
// Mounted, but out of the accessibility tree while closed.
expect(screen.queryByRole('textbox', { name: new RegExp(label('engine.packages.create.id')) })).toBeNull();
expect(screen.queryByRole('textbox', { name: new RegExp(label('engine.packages.create.version')) })).toBeNull();
});

it('a name alone creates the package, with the id and namespace it derived', async () => {
const user = userEvent.setup();
const f = openCreate();
await user.type(f.name(), 'Field Service');

expect(screen.getByTestId('package-form-derived-summary')).toHaveTextContent(
tFormat('engine.packages.create.derivedSummary', 'en-US', {
id: 'com.field-service',
namespace: 'field_service',
}),
);
expect(f.toggle()).toHaveAttribute('aria-expanded', 'false');

await user.click(f.submit());
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('/api/v1/packages');
expect(JSON.parse(String(init.body)).manifest).toMatchObject({
id: 'com.field-service',
namespace: 'field_service',
name: 'Field Service',
version: '0.1.0',
type: 'app',
});
});

it('Advanced shows the derived id and namespace, and both are editable there', async () => {
const user = userEvent.setup();
const f = openCreate();
await user.type(f.name(), 'Field Service');
await user.click(f.toggle());

expect(f.content()).not.toHaveAttribute('hidden');
const id = screen.getByRole('textbox', { name: new RegExp(label('engine.packages.create.id')) });
const ns = screen.getByRole('textbox', { name: new RegExp(label('engine.packages.create.namespace')) });
expect(id).toHaveValue('com.field-service');
expect(ns).toHaveValue('field_service');

await user.clear(ns);
await user.type(ns, 'fs');
expect(ns).toHaveValue('fs');
expect(screen.getByTestId('package-form-derived-summary')).toHaveTextContent(
tFormat('engine.packages.create.derivedSummary', 'en-US', { id: 'com.field-service', namespace: 'fs' }),
);
});

it('an edited id never moves when the name changes again', async () => {
const user = userEvent.setup();
const f = openCreate();
await user.type(f.name(), 'Field Service');
await user.click(f.toggle());
await user.clear(f.id());
await user.type(f.id(), 'com.acme.repairs');

await user.clear(f.name());
await user.type(f.name(), 'Repairs Center');
expect(f.id()).toHaveValue('com.acme.repairs');
expect(f.ns()).toHaveValue('repairs');
});

it('a hand-typed id the spec refuses is refused in words, Create waits, and Advanced cannot fold it away', async () => {
const user = userEvent.setup();
const f = openCreate();
await user.type(f.name(), 'Field Service');
await user.click(f.toggle());
await user.clear(f.id());
await user.type(f.id(), 'acme');

expect(screen.getByTestId('pkg-id-format-hint')).toHaveTextContent(
tFormat('engine.packages.idRule.formatHint', 'en-US', { example: 'com.acme.crm' }),
);
expect(f.submit()).toBeDisabled();
expect(screen.queryByTestId('package-form-derived-summary')).toBeNull();

await user.click(f.toggle());
expect(f.content()).not.toHaveAttribute('hidden');
expect(screen.getByTestId('pkg-id-format-hint')).toBeVisible();
});

it('a name that derives no id opens Advanced by itself, at the line that says why', () => {
const f = openCreate();
fireEvent.change(f.name(), { target: { value: '工单中心' } });

expect(f.submit()).toBeDisabled();
expect(f.toggle()).toHaveAttribute('aria-expanded', 'true');
expect(f.content()).not.toHaveAttribute('hidden');
expect(screen.getByTestId('package-form-id-missing')).toHaveTextContent(
tFormat('engine.packages.idRule.notDerived', 'en-US', { example: 'com.acme.crm' }),
);
});
});

describe('editing an existing package keeps its fields (objectstack#27833)', () => {
it('has no Advanced disclosure of its own and shows the locked id with the other rows', () => {
const manifest = { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', type: 'app', namespace: 'crm' };
render(<PackageFormDialog mode="edit" open onOpenChange={vi.fn()} manifest={manifest} />);

expect(screen.queryByTestId('package-form-advanced')).toBeNull();
expect(screen.queryByTestId('package-form-derived-summary')).toBeNull();
const id = screen.getByLabelText(label('engine.packages.create.id'), { exact: false }) as HTMLInputElement;
expect(id).toHaveValue('com.acme.crm');
expect(id).toHaveAttribute('readonly');
expect(screen.getByRole('textbox', { name: new RegExp(label('engine.packages.create.version')) })).toHaveValue(
'1.0.0',
);
});
});
Loading
Loading