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
13 changes: 13 additions & 0 deletions .changeset/10202-studio-use-picklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': minor
---

Studio reads shared picklists: a select field can use one, an object with a picklist-bound field saves again, and the picklist page is read-only (objectui#10202, the objectui half of objectstack#18164 phase 2).

A shared picklist is one list of options that select fields on several objects name with `picklist: 'NAME'` instead of carrying their own (`@objectstack/spec` 17.6.0, `data/picklist.zod.ts`). The kind is package-owned: it is authored in a package, and the runtime takes no create, edit or delete for it.

- **"Use picklist" in the select-field editor.** For a `select`, `radio`, `multiselect` or `checkboxes` field, an "Options from" picker lists the picklists the runtime serves, by name, beside "This field's own options". Choosing a picklist sets `picklist` and removes the field's `options`. A bound field shows the list's values read-only, and offers no inline options editor, because the spec refuses `picklist` together with `options`. Its default value is chosen from the list's values. Changing the field to a type that takes no options drops the binding. A stored name the served list no longer has is shown flagged as not found. If the list cannot be loaded, the picker says so and still offers the field's own options.
- **Saving an object with a picklist-bound field works again.** The runtime serves such a field with the options it resolved from the list and its extensions. The metadata-admin object designer and the Studio data page sent those back, and the save was refused with `422 INVALID_METADATA` whatever had been edited. Both now leave `options` out of every field that names a picklist. Every other field and key is sent as it was. The metadata-admin designer's live check now judges the body it sends, so it no longer reports "`picklist` and `options` cannot both be declared" on such an object.
- **The picklist page is read-only.** Its detail view shows the list's label, name, description and owning package, each option with its label and value, and the options other packages add to it (`picklistExtensions`), each under the package that declares it. It offers no create, edit or delete. The list page already offered no create for this kind.

Nothing is added to the package entry: no export, prop, type member or language-pack key. The new copy lives in the metadata-admin designer's own string tables (en and zh).
8 changes: 8 additions & 0 deletions packages/app-shell/src/services/builtinComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
isAggregatedViewContainer,
viewDisplayType,
} from '../views/metadata-admin/view-item-normalize.js';
import { dropServedPicklistOptions } from '../views/metadata-admin/picklist-binding.js';

/* -------------------------------------------------------------------------- */
/* 1) Top-level admin pages — bound to `metadata:directory` + `metadata:resource` */
Expand Down Expand Up @@ -158,6 +159,13 @@ registerMetadataResource({
{ key: 'label', label: 'Label', width: '25%' },
{ key: 'description', label: 'Description' },
],
// objectui#10202 — the editor is seeded from the SERVED object, where a
// picklist-bound field carries `picklist` plus the options resolved from the
// list; the authoring door refuses the two together. The saved body drops
// those resolved `options` from every picklist-bound field and touches
// nothing else. The draft is not changed, so the canvas keeps showing the
// list's options. See `dropServedPicklistOptions`.
fromDraft: dropServedPicklistOptions,
});

registerMetadataResource({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#10202 — the Studio picklist page is READ-ONLY, through the generic
* resource route (ruling 6006062645 item (c)).
*
* The kind is package-owned: its `/meta/types` row declares
* `allowRuntimeCreate: false` and `allowOrgOverride: false`, and the layered
* read of a packaged list answers `lock: 'full'`, `editable: false`,
* `deletable: false` (all measured on objectstack `main`). The generic list and
* edit pages already honour that row, so this card adds no read-only mechanism
* of its own: it registers the detail view (`PicklistPreview`) and pins that
* the page built from the two offers no create, no edit and no delete — the
* three things the server refuses for this kind.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';

type Row = Record<string, unknown>;

/** The `/meta/types` row for `picklist`, as the runtime serves it. */
const PICKLIST_ENTRY = {
type: 'picklist',
label: 'Picklist',
description: 'Shared option list that select fields reference by name',
supportsOverlay: false,
allowOrgOverride: false,
allowRuntimeCreate: false,
supportsVersioning: false,
domain: 'data',
schema: { type: 'object', properties: { name: { type: 'string' }, label: { type: 'string' } } },
};

const LIST: Row = {
name: 'showcase_priority_tier',
label: 'Priority Tier',
options: [
{ label: 'Gold', value: 'gold' },
{ label: 'Silver', value: 'silver' },
],
_packageId: 'com.example.showcase',
_provenance: 'package',
};

const PACKAGE_ROW = { manifest: { id: 'com.example.showcase', name: 'ObjectStack Showcase', scope: 'project' } };

const writes = vi.hoisted(() => [] as string[]);

const client = vi.hoisted(() => ({
list: async (type: string) => (type === 'picklist' ? [LIST] : type === 'package' ? [PACKAGE_ROW] : []),
listDrafts: async () => [],
get: async () => LIST,
references: async () => [],
layered: async () => ({
code: LIST,
overlay: null,
overlayScope: null,
effective: LIST,
lock: 'full',
provenance: 'package',
packageId: 'com.example.showcase',
editable: false,
deletable: false,
resettable: true,
}),
getDraft: async () => null,
save: async (type: string, name: string) => {
writes.push(`save ${type}/${name}`);
return {};
},
reset: async (type: string, name: string) => {
writes.push(`reset ${type}/${name}`);
return {};
},
publish: async (type: string, name: string) => {
writes.push(`publish ${type}/${name}`);
return {};
},
}));

vi.mock('./useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('./useMetadata')>();
return {
...mod,
useMetadataClient: () => client,
useMetadataTypes: () => ({ entries: [PICKLIST_ENTRY] }),
};
});

vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })),
);

import { MetadataResourceEditPage } from './ResourceEditPage';
import { MetadataResourceListPage } from './ResourceListPage';
import './register-builtins';
import '../../services/builtinComponents.js';

// Pre-declared so a regression to an enabled button or a link reads by name.
const WRITE_VERB = /^(new|create|save|edit|delete|reset|publish|add)\b/i;

afterEach(() => {
cleanup();
writes.length = 0;
});

describe('Studio picklist page — read-only through the generic route (objectui#10202)', () => {
it('the list offers no create', async () => {
render(
<MemoryRouter initialEntries={['/apps/setup/metadata/picklist?package=com.example.showcase']}>
<Routes>
<Route path="/apps/:appName/metadata/:type" element={<MetadataResourceListPage type="picklist" />} />
</Routes>
</MemoryRouter>,
);
expect(await screen.findByText('showcase_priority_tier', {}, { timeout: 8000 })).toBeInTheDocument();
const offered = screen.queryAllByRole('button').filter((b) => WRITE_VERB.test((b.textContent ?? '').trim()));
expect(offered.map((b) => b.textContent)).toEqual([]);
});

it('the detail page shows the list through its preview, and offers no edit or delete', async () => {
render(
<MemoryRouter initialEntries={['/metadata/picklist/showcase_priority_tier']}>
<MetadataResourceEditPage type="picklist" name="showcase_priority_tier" />
</MemoryRouter>,
);
const options = await screen.findByRole('region', { name: 'Options' }, { timeout: 8000 });
expect(within(options).getByText('Gold')).toBeInTheDocument();
expect(within(options).getByText('Silver')).toBeInTheDocument();

// Every enabled control's name, and every write verb among them.
const enabledWrites = screen
.queryAllByRole('button')
.filter((b) => !(b as HTMLButtonElement).disabled)
.map((b) => (b.getAttribute('aria-label') || b.getAttribute('title') || b.textContent || '').trim())
.filter((n) => WRITE_VERB.test(n));
expect(enabledWrites).toEqual([]);
expect(writes).toEqual([]);
});
});
Loading
Loading