diff --git a/.changeset/30064-studio-dataset-customize.md b/.changeset/30064-studio-dataset-customize.md new file mode 100644 index 00000000000..0d1bc3b69bc --- /dev/null +++ b/.changeset/30064-studio-dataset-customize.md @@ -0,0 +1,16 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/data-objectstack': patch +'@object-ui/i18n': patch +--- + +feat(studio): customize a read-only package's dataset in the All metadata area, see what uses it before publishing, and read a refused publish at the dataset + +Clause-②: yes (widening) +Metadata-protocol: none + +- **Customize a dataset.** On a read-only base package, Studio's All metadata area opens a dataset in its form editor with the dataset designer and live preview, and offers Customize. The first saved edit copies the whole dataset into the base's paired `.custom` package, declaring `overrides: { package, basedOn }`; the header's Publish applies it, and "Restore package original" on the override mark deletes the copy. On a writable package a dataset still opens the generic item page. +- **What uses it.** While a dataset is customized, the editor lists the dashboards, reports and other items that name it, read from the References read, so the person sees what a published change reaches. A failed read says it failed and offers to read again; it never reads as "nothing uses it". +- **A refused publish at the item.** A publish the server refuses for a dataset is placed on its measure or dimension and the designer's input (`Not published. Check Aggregate of the measure "Total Amount"`), in the Console's language, and shown at the dataset's editor as well as on its Pending changes entry. The server's own text stays under Details. +- **References read fixed.** `MetadataClient.references()` now reads the route's declared answer (`{ references: [{ type, name, label, path, kind }] }`) and maps each element to `MetadataReference` (`fromType`, `fromName`, new optional `fromLabel` and `kind`). It used to read a key the route never serves, so Setup's References tab and Studio's item tools showed "Nothing in the metadata graph points at this item. Safe to delete." for every item. An answer the declaration refuses is now a failed read. +- **Published surface.** `MetadataReference`, exported from `@object-ui/data-objectstack`, gains two optional fields, `fromLabel` (the referring item's label, when the server serves one) and `kind` (the served reference kind); existing fields are unchanged. `MetadataClient.references()` now accepts less: a `200` answer that `FindReferencesToMetaResponseSchema` refuses throws instead of resolving `[]`, so a caller must handle the rejection as it already does for a non-2xx answer; in this repository every caller reads it through `useReferencesScan`, which catches it and shows the failed-check state. diff --git a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx index 189fbf2ec9c..48cd7b9ef21 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx @@ -24,6 +24,12 @@ * - Restore deletes the pair's draft and row of that one member, and the * base's version reads back; * - a walled caller sees Customize disabled, and nothing is asked or sent. + * + * objectstack#30064 — a read-only base's dataset, in the same editor with the + * type's registered designer (a probe here): the whole dataset written into + * the pair, declared; what uses it listed from the references read before + * anything is published, a failed read said as failed; a designer's blocking + * issue holding the edit unsent; and a kept publish refusal shown at the item. */ import '@testing-library/jest-dom/vitest'; @@ -90,6 +96,7 @@ const mockClient = vi.hoisted(() => ({ srv.version += 1; return { success: true, version: `v${srv.version}`, state: 'draft' }; }), + references: vi.fn(async (_type: string, _name: string): Promise => []), reset: vi.fn(async (type: string, name: string, opts: Record = {}) => { srv.resets.push({ type, name, opts: clone(opts) }); const map = opts.state === 'draft' ? srv.drafts : srv.rows; @@ -115,6 +122,43 @@ import type { StudioBaseCustomization } from './StudioDesignSurface'; import { ensureCustomizationPackage } from './packages-io'; import { registerMetadataPreview } from '../metadata-admin/preview-registry'; import { registerDefaultMetadataSchemas } from '../metadata-admin/default-schemas'; +import { + registerMetadataDefaultInspector, + type MetadataDefaultInspectorProps, +} from '../metadata-admin/default-inspector-registry'; +import type { StudioRefusal } from './metadataError'; + +/** + * The dataset's designer slot: the draft it is handed, its read-only gate, one + * edit (the second measure's aggregate) and one blocking report. + */ +function DesignerProbe({ draft, onPatch, onBlockingIssuesChange, readOnly }: MetadataDefaultInspectorProps) { + const measures = Array.isArray(draft.measures) ? (draft.measures as Array>) : []; + return ( +
+

{String(measures[1]?.aggregate ?? '')}

+ + +
+ ); +} /** The preview slot, printing the live draft it is handed. */ function PreviewProbe({ draft }: { draft: Record }) { @@ -134,6 +178,37 @@ const WELCOME_EN = { _packageId: BASE, }; const WELCOME_ZH = { ...WELCOME_EN, locale: 'zh-CN', subject: '欢迎使用 HotCRM', bodyHtml: '

你好 {{user.name}}

' }; +const METRICS = { + name: 'opportunity_metrics', + label: 'Opportunity Metrics', + object: 'crm_opportunity', + dimensions: [{ name: 'stage', label: 'Stage', field: 'stage', type: 'string' }], + measures: [ + { name: 'opp_count', label: 'Opportunities', aggregate: 'count' }, + { name: 'total_amount', label: 'Total Amount', aggregate: 'sum', field: 'amount' }, + ], + _packageId: BASE, +}; +/** What the references read answers for the dataset (`client.references`, mapped from the served `references[]`). */ +const USED_BY = [ + { + fromType: 'dashboard', + fromName: 'pipeline_dashboard', + fromLabel: 'Pipeline Dashboard', + path: 'widgets[].dataset', + kind: 'dashboard dataset', + value: 'opportunity_metrics', + }, + { + fromType: 'dashboard', + fromName: 'pipeline_dashboard', + fromLabel: 'Pipeline Dashboard', + path: 'widgets[].compare.dataset', + kind: 'dashboard dataset', + value: 'opportunity_metrics', + }, + { fromType: 'report', fromName: 'won_by_stage', path: 'dataset', kind: 'report dataset', value: 'opportunity_metrics' }, +]; const BUNDLE_ZH = { name: 'hotcrm_zh', label: 'HotCRM zh-CN', @@ -146,6 +221,7 @@ beforeAll(() => { registerDefaultMetadataSchemas(); registerMetadataPreview('email_template', PreviewProbe as never); registerMetadataPreview('translation', PreviewProbe as never); + registerMetadataDefaultInspector('dataset', DesignerProbe); }); beforeEach(() => { @@ -155,6 +231,7 @@ beforeEach(() => { at(srv.rows, BASE).set(slot('email_template', 'welcome', 'zh-CN'), clone(WELCOME_ZH)); at(srv.rows, BASE).set(slot('translation', 'hotcrm_zh'), clone(BUNDLE_ZH)); at(srv.rows, WRITABLE).set(slot('email_template', 'welcome', 'zh-CN'), { ...clone(WELCOME_ZH), _packageId: WRITABLE }); + at(srv.rows, BASE).set(slot('dataset', 'opportunity_metrics'), clone(METRICS)); srv.pairExists = false; srv.ensure.length = 0; srv.saves.length = 0; @@ -196,6 +273,7 @@ function Host({ packageId = BASE, walled = false, listedPair = null, + publishRefusal = null, }: { type: string; name: string; @@ -203,6 +281,7 @@ function Host({ packageId?: string; walled?: boolean; listedPair?: string | null; + publishRefusal?: StudioRefusal | null; }) { const readOnly = packageId === BASE; const [pairId, setPairId] = React.useState(listedPair); @@ -228,6 +307,7 @@ function Host({ readOnly={readOnly} customization={customization} basePath={`/studio/${packageId}/metadata`} + publishRefusal={publishRefusal} /> ); } @@ -437,3 +517,104 @@ describe('Customize a read-only base’s translation bundle (objectstack#28569)' await waitFor(() => expect(screen.queryByTestId('studio-bundle-editor-whole-bundle')).toBeNull()); }); }); + +describe('Customize a read-only base’s dataset (objectstack#30064)', () => { + beforeEach(() => { + mockClient.references.mockImplementation(async () => clone(USED_BY)); + }); + + it('opens the designer read-only, says the whole dataset is copied, and lists what uses it before anything is published', async () => { + mount({ type: 'dataset', name: 'opportunity_metrics' }); + const probe = await screen.findByTestId('designer-probe', undefined, { timeout: 8000 }); + expect(probe).toHaveAttribute('data-readonly', 'true'); + expect(screen.getByTestId('designer-probe-aggregate')).toHaveTextContent('sum'); + expect(screen.getByTestId('studio-bundle-editor-whole-dataset')).toHaveTextContent( + 'Customizing a dataset copies HotCRM’s whole dataset into your customization', + ); + const usedBy = await screen.findByTestId('studio-bundle-editor-used-by'); + await waitFor(() => expect(usedBy).toHaveAttribute('data-state', 'loaded')); + expect(mockClient.references).toHaveBeenCalledWith('dataset', 'opportunity_metrics'); + // One entry per referring item: the dashboard binding it twice is one dashboard. + const items = within(usedBy).getAllByTestId('studio-bundle-editor-used-by-item'); + expect(items.map((el) => [el.getAttribute('data-type'), el.getAttribute('data-name')])).toEqual([ + ['dashboard', 'pipeline_dashboard'], + ['report', 'won_by_stage'], + ]); + expect(items[0]).toHaveTextContent('Pipeline Dashboard'); + expect(items[1]).toHaveTextContent('won_by_stage'); + expect(usedBy).toHaveTextContent('reaches the 2 items built on this dataset'); + expect(srv.saves).toEqual([]); + }); + + it('Customize and an edit in the designer write the whole dataset into the pair’s draft, declared; the base is untouched', async () => { + mount({ type: 'dataset', name: 'opportunity_metrics' }); + await screen.findByTestId('designer-probe', undefined, { timeout: 8000 }); + await customize(); + await waitFor(() => expect(screen.getByTestId('designer-probe')).toHaveAttribute('data-readonly', 'false')); + fireEvent.click(screen.getByTestId('designer-probe-avg')); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 5000 }); + expect(srv.ensure).toEqual([201]); + const [save] = srv.saves; + expect(save).toMatchObject({ type: 'dataset', name: 'opportunity_metrics' }); + expect(save.opts).toMatchObject({ mode: 'draft', packageId: PAIR, ifNoneMatch: '*' }); + const { _packageId: _dropped, ...whole } = clone(METRICS); + expect(save.body).toEqual({ + ...whole, + measures: [whole.measures[0], { ...whole.measures[1], aggregate: 'avg' }], + overrides: { package: BASE, basedOn: '4.0.0' }, + }); + expect(await screen.findByTestId('studio-override-chip')).toHaveTextContent('Overrides HotCRM'); + await waitFor(() => expect(screen.queryByTestId('studio-bundle-editor-whole-dataset')).toBeNull()); + // What uses it stays in view until the change is published. + expect(screen.getByTestId('studio-bundle-editor-used-by')).toBeInTheDocument(); + expect(srv.rows.get(BASE)?.get(slot('dataset', 'opportunity_metrics'))).toEqual(METRICS); + }); + + it('holds an edit the designer reports as blocking: nothing is sent', async () => { + mount({ type: 'dataset', name: 'opportunity_metrics' }); + await screen.findByTestId('designer-probe', undefined, { timeout: 8000 }); + await customize(); + await waitFor(() => expect(screen.getByTestId('designer-probe')).toHaveAttribute('data-readonly', 'false')); + fireEvent.click(screen.getByTestId('designer-probe-blank-dimension')); + await sleep(2500); + expect(srv.ensure).toEqual([]); + expect(srv.saves).toEqual([]); + }); + + it('a references read that failed says so and asks again; it never reads as nothing using the dataset', async () => { + mockClient.references.mockRejectedValueOnce(Object.assign(new Error('Service Unavailable'), { status: 503 })); + mount({ type: 'dataset', name: 'opportunity_metrics' }); + const usedBy = await screen.findByTestId('studio-bundle-editor-used-by', undefined, { timeout: 8000 }); + await waitFor(() => expect(usedBy).toHaveAttribute('data-state', 'error')); + expect(within(usedBy).getByRole('alert')).toHaveTextContent('could not be read'); + expect(usedBy).not.toHaveTextContent('Nothing uses this dataset'); + fireEvent.click(within(usedBy).getByTestId('studio-bundle-editor-used-by-retry')); + await waitFor(() => expect(usedBy).toHaveAttribute('data-state', 'loaded')); + expect(within(usedBy).getAllByTestId('studio-bundle-editor-used-by-item')).toHaveLength(2); + }); + + it('a read that answers no reference says nothing uses the dataset', async () => { + mockClient.references.mockImplementation(async () => []); + mount({ type: 'dataset', name: 'opportunity_metrics' }); + const usedBy = await screen.findByTestId('studio-bundle-editor-used-by', undefined, { timeout: 8000 }); + await waitFor(() => expect(usedBy).toHaveAttribute('data-state', 'loaded')); + expect(usedBy).toHaveTextContent('Nothing uses this dataset yet'); + }); + + it('shows the refusal the header’s Publish kept for the dataset at the item, with the server’s text under Details', async () => { + mount({ + type: 'dataset', + name: 'opportunity_metrics', + listedPair: PAIR, + publishRefusal: { + message: 'Not published. Check Aggregate of the measure “Total Amount”: Details say what to change.', + target: null, + input: 'Aggregate', + detail: 'datasets.opportunity_metrics.measures[1].aggregate — aggregate "sum" over field "expected_revenue" is refused', + }, + }); + const strip = await screen.findByTestId('studio-bundle-editor-publish-refusal', undefined, { timeout: 8000 }); + expect(within(strip).getByRole('alert')).toHaveTextContent('Check Aggregate of the measure “Total Amount”'); + expect(within(strip).getByTestId('studio-refusal-detail')).toHaveTextContent('expected_revenue'); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx index e0d004151c7..8500a768810 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx @@ -31,6 +31,17 @@ * edit never moves into another member. A translation is one locale bundle * whose override replaces the whole bundle (ADR-0146 D2), and the editor says * so before the first save. + * + * ## A dataset (objectstack#30064) + * + * On a read-only base the area opens a dataset here too + * (`metadataArea.ts#AREA_CUSTOMIZE_EDITOR_TYPES`): the type's registered + * designer (`DatasetDefaultInspector`) in place of the form, beside its + * preview, customized as above, the whole dataset copied into the pair. A + * dataset feeds every dashboard and report built on it, so the editor lists + * what uses it, from the references read (`client.references`), before + * anything is published; and a refusal the header's Publish kept for it is + * shown here, at the item, as on its Pending changes entry. */ import * as React from 'react'; @@ -44,15 +55,18 @@ import { cn, } from '@object-ui/components'; import { extractDraftBody, formatMetadataError } from '@object-ui/data-objectstack'; -import { History, Lock, MoreHorizontal } from 'lucide-react'; +import { History, Link2, Lock, MoreHorizontal } from 'lucide-react'; import { SchemaForm } from '../metadata-admin/SchemaForm.js'; import { resolveResourceConfig } from '../metadata-admin/registry.js'; import { useRegisteredMetadataPreview } from '../metadata-admin/preview-registry.js'; +import { useRegisteredMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry.js'; +import { useReferencesScan, type ReferencesState } from '../item-tools/ReferencesPanel.js'; import { useMetadataClient, useMetadataTypes } from '../metadata-admin/useMetadata.js'; import { readDraftForSave, useDraftSaveGuard } from '../metadata-admin/DraftConflictDialog.js'; import { t, tFormat, translateMetadataType, useMetadataLocale } from '../metadata-admin/i18n.js'; import { emitMetadataRefresh } from '../../assistant/assistantBus.js'; import { plainRefusal, type StudioRefusal } from './metadataError.js'; +import { StudioRefusalStrip } from './StudioRefusalStrip.js'; import { useDraftAutoSave, type DraftSend } from './useDraftAutoSave.js'; import { StudioCustomizeButton, @@ -79,10 +93,119 @@ function savedBody(doc: Record): Record { return body; } +/** The referring items of a references read, one per item: a dashboard binding it twice is one dashboard. */ +function referringItems(state: ReferencesState): Array<{ type: string; name: string; label: string }> { + if (state.status !== 'loaded') return []; + const seen = new Set(); + const out: Array<{ type: string; name: string; label: string }> = []; + for (const ref of state.items) { + const key = `${ref.fromType}/${ref.fromName}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ type: ref.fromType, name: ref.fromName, label: ref.fromLabel || ref.fromName }); + } + return out; +} + +/** + * objectstack#30064 — what uses this item, read once by the references read + * (`client.references`, the item tools' 引用关系): a dataset's dashboards and + * reports, each opening in the area. A failed read says it failed and offers + * the read again; it never reads as "nothing uses it". + */ +function StudioBundleUsedBy({ + type, + name, + packageId, + basePath, + locale, +}: { + type: string; + name: string; + packageId: string; + basePath: string; + locale: string; +}): React.ReactElement { + const navigate = useNavigate(); + const client = useMetadataClient(); + const { state, load } = useReferencesScan(client, type, name); + const loadRef = React.useRef(load); + React.useLayoutEffect(() => { + loadRef.current = load; + }); + React.useEffect(() => { + void loadRef.current(); + }, []); + const items = referringItems(state); + return ( +
+ {state.status === 'error' ? ( +
+

+ {t('engine.studio.area.formEditor.usedByError', locale)} +

+ +
+ ) : state.status !== 'loaded' ? ( +

+ {t('engine.edit.refsScanning', locale)} +

+ ) : items.length === 0 ? ( +

{t('engine.studio.area.formEditor.usedByNone', locale)}

+ ) : ( + <> +

+

+
    + {items.map((item) => ( +
  • + +
  • + ))} +
+ + )} +
+ ); +} + export interface StudioBundleItemEditorProps { /** The package the area is open on. */ packageId: string; - /** `email_template` or `translation` (`AREA_FORM_EDITOR_TYPES`). */ + /** `email_template` or `translation` (`AREA_FORM_EDITOR_TYPES`), or a read-only package's `dataset`. */ type: string; name: string; /** The bundle member the URL names (`?member=`); absent for the canonical one and for a translation. */ @@ -97,6 +220,8 @@ export interface StudioBundleItemEditorProps { onDraftSaved?: (saved?: { type: string; name: string }) => void; /** The area's root (`MetadataAreaHost.basePath`), for the item's history link. */ basePath: string; + /** objectstack#30064 — what the header's last Publish refused for this item, kept until it is resolved. */ + publishRefusal?: StudioRefusal | null; } export function StudioBundleItemEditor({ @@ -109,6 +234,7 @@ export function StudioBundleItemEditor({ publishNonce = 0, onDraftSaved, basePath, + publishRefusal = null, }: StudioBundleItemEditorProps): React.ReactElement { const locale = useMetadataLocale(); const navigate = useNavigate(); @@ -121,6 +247,10 @@ export function StudioBundleItemEditor({ (entry?.schema as Record | undefined) ?? (config.defaultSchema as Record | undefined); const Preview = useRegisteredMetadataPreview(type); + // objectstack#30064 — a type with a designer of its own (a dataset) is edited in it, not in the form. + const Designer = useRegisteredMetadataDefaultInspector(type); + // What the designer reports as blocking (a dimension with no field): held, never sent. + const [blocking, setBlocking] = React.useState(0); const memberScope = React.useMemo(() => (member ? { member } : {}), [member]); const target = JSON.stringify([packageId, type, name, member ?? '']); @@ -304,7 +434,7 @@ export function StudioBundleItemEditor({ target, loadedFor, dirty, - blocked: !editable || restoring, + blocked: !editable || restoring || blocking > 0, snapshot: draft, save: doSave, }); @@ -340,6 +470,11 @@ export function StudioBundleItemEditor({ setDraft(next); setDirty(true); }, []); + // The designer's shallow patch, as the generic item page applies it. + const onPatch = React.useCallback((patch: Record) => { + setDraft((prev) => ({ ...prev, ...patch })); + setDirty(true); + }, []); const baseLabel = customization?.baseLabel ?? packageId; // The member the URL names, else the one the canonical read served. @@ -444,9 +579,22 @@ export function StudioBundleItemEditor({ })}

)} + {customizing && !inPair && type === 'dataset' && ( +

+ {tFormat('engine.studio.area.formEditor.datasetWhole', locale, { package: baseLabel })} +

+ )} + {customizing && type === 'dataset' && ( + + )} {customizing && unlocked && (

{t('engine.studio.customize.draftFooter', locale)}

)} + {publishRefusal && ( +
+ +
+ )} {shownError && (

@@ -473,15 +621,29 @@ export function StudioBundleItemEditor({ )} >

- + {Designer ? ( + // eslint-disable-next-line react-hooks/static-components -- useRegisteredMetadataDefaultInspector returns a registered component (stable), not one created during render + | undefined} + /> + ) : ( + + )}
{Preview && (
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx index 26d44b89b6f..8c19ca549ed 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx @@ -71,7 +71,9 @@ import { readFields, writeFields, type FieldEntry } from '../metadata-admin/prev * app is copied into the pair on the first saved navigation edit). Each is an * `overridable` type of the spec's classification. Translations and email * templates are too: no pillar designs them, and the All metadata area's form - * editor customizes them (`StudioBundleItemEditor`, objectstack#28569). + * editor customizes them (`StudioBundleItemEditor`, objectstack#28569). So is + * a read-only package's dataset, in that editor with its own designer + * (objectstack#30064, `metadataArea.ts#AREA_CUSTOMIZE_EDITOR_TYPES`). */ export const STUDIO_CUSTOMIZE_TYPES: readonly string[] = ['view', 'dashboard', 'report', 'app']; diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index d5dadbcbc07..f1d1c431fd7 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -1146,6 +1146,12 @@ function StudioDesignSurfaceBody({ aiSlot, area }: StudioDesignSurfaceProps): Re }, [listedPublishRefusals, locale], ); + // objectstack#30064 — the same kept refusal, for the All metadata area's + // editor of the item it refused (a customized dataset), shown at the item. + const publishRefusalFor = React.useCallback( + (type: string, name: string): StudioRefusal | null => listedPublishRefusals.get(publishRefusalKey(type, name)) ?? null, + [listedPublishRefusals], + ); const shownPendingCount = behindSave ? Math.max(pendingCount ?? 0, 1) : pendingCount; const hasPending = (shownPendingCount ?? 0) > 0; // objectui#12725 — the same ledger and number, for the pillars' item headers @@ -1752,6 +1758,7 @@ function StudioDesignSurfaceBody({ aiSlot, area }: StudioDesignSurfaceProps): Re customization={customization} publishNonce={publishNonce} onDraftSaved={onDraftSaved} + publishRefusalFor={publishRefusalFor} /> ) : !pillarKnown ? ( /:type/new create (a writable package only) * /:type/:name the generic item page, the item's pillar, or * the form editor of an email template or a - * translation (`StudioBundleItemEditor`, #28569) + * translation (`StudioBundleItemEditor`, #28569), + * and of a read-only package's dataset (#30064) * /:type/:name/history the item's history * * Mirroring Setup's shapes keeps every relative link the pages emit pointing @@ -49,6 +50,7 @@ import { import { StudioBundleItemEditor } from './StudioBundleItemEditor.js'; import { BUNDLE_MEMBER_PARAM } from '../metadata-admin/registry.js'; import type { StudioBaseCustomization } from './StudioDesignSurface.js'; +import type { StudioRefusal } from './metadataError.js'; /* * The pages load with the area, through the specifier `AppContent` loads them @@ -118,6 +120,7 @@ interface AreaEditorProps { customization: StudioBaseCustomization | null; publishNonce: number; onDraftSaved?: (saved?: { type: string; name: string }) => void; + publishRefusalFor?: (type: string, name: string) => StudioRefusal | null; } /** @@ -152,7 +155,7 @@ function AreaItemOpen({ const host = useMetadataAreaHost(); const [opens] = React.useState(() => { const at = { packageId: host?.packageId ?? null, type, search }; - if (areaItemOpensFormEditor(at)) return { formEditor: true, pillarPath: null }; + if (areaItemOpensFormEditor({ ...at, readOnly: host?.readOnly === true })) return { formEditor: true, pillarPath: null }; return { formEditor: false, pillarPath: areaItemPillarPath({ ...at, name }) }; }); if (opens.pillarPath) return ; @@ -167,6 +170,7 @@ function AreaItemOpen({ customization={editor.customization} publishNonce={editor.publishNonce} onDraftSaved={editor.onDraftSaved} + publishRefusal={editor.publishRefusalFor?.(type, name) ?? null} basePath={host.basePath} /> ); @@ -198,6 +202,12 @@ export interface StudioMetadataAreaProps { publishNonce?: number; /** Told when the form editor saves a draft, so the header's pending count follows it. */ onDraftSaved?: (saved?: { type: string; name: string }) => void; + /** + * objectstack#30064 — what the header's last Publish refused for an item, + * kept until it is resolved (objectstack#26730): the form editor shows it + * at the item it refused. + */ + publishRefusalFor?: (type: string, name: string) => StudioRefusal | null; } export function StudioMetadataArea({ @@ -205,6 +215,7 @@ export function StudioMetadataArea({ customization = null, publishNonce = 0, onDraftSaved, + publishRefusalFor, }: StudioMetadataAreaProps): React.ReactElement { const { pathname, search } = useLocation(); const locale = useMetadataLocale(); @@ -219,7 +230,7 @@ export function StudioMetadataArea({ () => ({ packageId, readOnly: packageId !== null && writable === false, basePath }), [packageId, writable, basePath], ); - const editor: AreaEditorProps = { customization, publishNonce, onDraftSaved }; + const editor: AreaEditorProps = { customization, publishNonce, onDraftSaved, publishRefusalFor }; // The query names the leg (`areaLocationRedirect`). Settled before any page // mounts, so no page reads a scope the URL is about to change. diff --git a/ui/packages/app-shell/src/views/studio-design/metadataArea.test.ts b/ui/packages/app-shell/src/views/studio-design/metadataArea.test.ts index 4e695a553de..0cd6b3c89e7 100644 --- a/ui/packages/app-shell/src/views/studio-design/metadataArea.test.ts +++ b/ui/packages/app-shell/src/views/studio-design/metadataArea.test.ts @@ -13,6 +13,7 @@ import { describe, it, expect } from 'vitest'; import { AREA_PILLAR_FOR_TYPE, STUDIO_ALL_PACKAGES_SEGMENT, + areaItemOpensFormEditor, areaItemPillarPath, areaLocationRedirect, isStudioAllPackagesScope, @@ -118,3 +119,31 @@ describe('areaItemPillarPath — a pillar type opens its pillar', () => { expect(open('flow', '?review=0')).not.toBeNull(); }); }); + +describe('areaItemOpensFormEditor — a read-only package’s dataset opens its Customize door (objectstack#30064)', () => { + const at = (type: string, readOnly: boolean, search = '') => ({ + packageId: PKG, + type, + search: new URLSearchParams(search), + readOnly, + }); + + it('a dataset opens the form editor on a read-only package, and the generic page on a writable one', () => { + expect(areaItemOpensFormEditor(at('dataset', true))).toBe(true); + expect(areaItemOpensFormEditor(at('dataset', false))).toBe(false); + expect(areaItemOpensFormEditor({ packageId: PKG, type: 'dataset', search: new URLSearchParams() })).toBe(false); + }); + + it('keeps the form editor’s exceptions: the cross-package leg, the environment scope and the draft review', () => { + expect(areaItemOpensFormEditor({ ...at('dataset', true), packageId: null })).toBe(false); + expect(areaItemOpensFormEditor(at('dataset', true, 'scope=environment'))).toBe(false); + expect(areaItemOpensFormEditor(at('dataset', true, 'review=1'))).toBe(false); + }); + + it('leaves an email template and a translation where they were, on either kind of package', () => { + for (const type of ['email_template', 'translation']) { + expect(areaItemOpensFormEditor(at(type, true))).toBe(true); + expect(areaItemOpensFormEditor(at(type, false))).toBe(true); + } + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/metadataArea.ts b/ui/packages/app-shell/src/views/studio-design/metadataArea.ts index 2145d0f4580..1f4b0758fc4 100644 --- a/ui/packages/app-shell/src/views/studio-design/metadataArea.ts +++ b/ui/packages/app-shell/src/views/studio-design/metadataArea.ts @@ -116,19 +116,35 @@ export function areaItemPillarPath(input: { */ export const AREA_FORM_EDITOR_TYPES: readonly string[] = ['email_template', 'translation']; +/** + * objectstack#30064 (ADR-0146 D2) — the types whose item opens the same editor + * on a READ-ONLY package only, where it is the item's Customize door: an + * analytics dataset, which no pillar designs. The editor mounts the type's + * registered designer (`DatasetDefaultInspector`) and preview. On a writable + * package the generic item page stays the dataset's editor, as on Setup's. + * `overridable` in the spec's classification. + */ +export const AREA_CUSTOMIZE_EDITOR_TYPES: readonly string[] = ['dataset']; + /** * Whether an item of the area opens the form editor rather than the generic - * item page: a form-editor type on a package's leg, under the same exceptions - * as {@link areaItemPillarPath} (the cross-package leg and the environment - * scope name no package to write, and `?review=1` opens the generic page's - * draft review). + * item page: a form-editor type, or on a read-only package a customize-editor + * type, on a package's leg, under the same exceptions as + * {@link areaItemPillarPath} (the cross-package leg and the environment scope + * name no package to write, and `?review=1` opens the generic page's draft + * review). */ export function areaItemOpensFormEditor(input: { packageId: string | null; type: string; search: URLSearchParams; + /** The package is read-only (the area's gate). */ + readOnly?: boolean; }): boolean { - if (!AREA_FORM_EDITOR_TYPES.includes(input.type) || !input.packageId) return false; + const opens = + AREA_FORM_EDITOR_TYPES.includes(input.type) || + (input.readOnly === true && AREA_CUSTOMIZE_EDITOR_TYPES.includes(input.type)); + if (!opens || !input.packageId) return false; if (input.search.get(CATALOG_SCOPE_PARAM) === ENVIRONMENT_SCOPE) return false; return input.search.get('review') !== '1'; } diff --git a/ui/packages/app-shell/src/views/studio-design/metadataError.publishRefusal-26730.test.ts b/ui/packages/app-shell/src/views/studio-design/metadataError.publishRefusal-26730.test.ts index 8285c84e97f..9d1a028db44 100644 --- a/ui/packages/app-shell/src/views/studio-design/metadataError.publishRefusal-26730.test.ts +++ b/ui/packages/app-shell/src/views/studio-design/metadataError.publishRefusal-26730.test.ts @@ -169,3 +169,66 @@ describe('only the causal failures are kept (ADR-0067 D2)', () => { expect(causalPublishFailures([aborted('visit'), aborted('ticket')])).toEqual([aborted('visit')]); }); }); + +describe('a refused dataset publish is placed on its measure or dimension (objectstack#30064)', () => { + /** The measured refusal: `sum` over a formula field, as the authoring gate answers it. */ + const formulaMeasureRefused: PublishFailure = { + type: 'dataset', + name: 'opportunity_metrics', + error: 'dataset/opportunity_metrics failed author-time validation: 1 issue', + code: 'INVALID_METADATA', + issues: [ + { + path: 'datasets.opportunity_metrics.measures[1].aggregate', + message: + 'aggregate "sum" over field "expected_revenue" (`formula` on object "crm_opportunity") is refused by the ' + + 'aggregate × field-type table: the number would depend on the SQL dialect', + }, + ], + }; + const DATASET = { + name: 'opportunity_metrics', + object: 'crm_opportunity', + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [ + { name: 'opp_count', label: 'Opportunities', aggregate: 'count' }, + { name: 'total_amount', label: 'Total Amount', aggregate: 'sum', field: 'expected_revenue' }, + ], + }; + + it('names the measure and the designer’s input, and keeps the engine text under Details', async () => { + await loadStudioCatalogue('zh'); + const refusal = publishRefusal(formulaMeasureRefused, 'dataset', DATASET, 'zh'); + const input = t('engine.inspector.dataset.aggregate', 'zh'); + expect(refusal.message).toBe( + tFormat('engine.studio.refusal.publishWhere', 'zh', { + where: tFormat('engine.studio.refusal.datasetMeasureInput', 'zh', { member: 'Total Amount', input }), + }), + ); + expect(refusal.message).not.toMatch(/expected_revenue|formula|datasets\./); + expect(refusal.target).toBeNull(); + expect(refusal.input).toBe(input); + expect(refusal.detail).toBe(formatMetadataError({ message: formulaMeasureRefused.error, issues: formulaMeasureRefused.issues })); + }); + + it('a dimension with no label is named by its name; a path under no member is left unplaced', () => { + const dimension = publishRefusal( + { ...formulaMeasureRefused, issues: [{ path: 'datasets.opportunity_metrics.dimensions[0]', message: 'x' }] }, + 'dataset', + DATASET, + 'en', + ); + expect(dimension.message).toBe( + tFormat('engine.studio.refusal.publishWhere', 'en', { + where: tFormat('engine.studio.refusal.datasetDimension', 'en', { member: 'stage' }), + }), + ); + const unplaced = publishRefusal( + { ...formulaMeasureRefused, issues: [{ path: 'datasets.opportunity_metrics.object', message: 'x' }] }, + 'dataset', + DATASET, + 'en', + ); + expect(unplaced.message).toBe(t('engine.studio.refusal.publishUnlocated', 'en')); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/metadataError.ts b/ui/packages/app-shell/src/views/studio-design/metadataError.ts index ebee95a1b61..e6971a1b9d7 100644 --- a/ui/packages/app-shell/src/views/studio-design/metadataError.ts +++ b/ui/packages/app-shell/src/views/studio-design/metadataError.ts @@ -548,6 +548,46 @@ export function reportInputLocator(locale: string): IssueLocator { }; } +/** objectstack#30064 — the dataset designer's own label for a key of one measure or dimension it edits. */ +const DATASET_MEMBER_INPUT_KEYS: Readonly> = { + field: 'engine.inspector.dataset.field', + aggregate: 'engine.inspector.dataset.aggregate', + type: 'engine.inspector.dataset.type', + dateGranularity: 'engine.inspector.dataset.dateBucket', + format: 'engine.inspector.dataset.displayFormat', + currency: 'engine.inspector.dataset.currency', + filter: 'engine.inspector.dataset.measureFilter', +}; + +/** + * objectstack#30064 — a dataset, in the All metadata area's editor: + * `measures.INDEX…` / `dimensions.INDEX…` on the body that was judged, placed + * on that measure or dimension by its label (its name when it has none), and + * on the designer's input when the key under it is one the designer edits. + * The designer has no canvas selection, so the target is the item (`null`). + */ +export function datasetMemberLocator(sent: SentBody, locale: string): IssueLocator { + return (path) => { + const list = path[0]; + if ((list !== 'measures' && list !== 'dimensions') || !/^\d+$/.test(path[1] ?? '')) return null; + const rows = sent[list]; + const member = Array.isArray(rows) ? rows[Number(path[1])] : undefined; + if (!isSentBody(member)) return null; + const title = + labelTextOf(member.label, locale) || + (typeof member.name === 'string' && member.name !== '' ? member.name : String(Number(path[1]) + 1)); + const inputKey = path[2] !== undefined ? DATASET_MEMBER_INPUT_KEYS[path[2]] : undefined; + const measure = list === 'measures'; + if (!inputKey) { + const key = measure ? 'engine.studio.refusal.datasetMeasure' : 'engine.studio.refusal.datasetDimension'; + return { where: tFormat(key, locale, { member: title }), selection: null }; + } + const input = t(inputKey, locale); + const key = measure ? 'engine.studio.refusal.datasetMeasureInput' : 'engine.studio.refusal.datasetDimensionInput'; + return { where: tFormat(key, locale, { member: title, input }), selection: null, input }; + }; +} + /** * objectstack#26730 — the locator for an Interfaces leaf of `type`: a * dashboard's widgets and a report's properties. Any other leaf (a page's @@ -630,6 +670,7 @@ function publishLocator(type: string, body: SentBody, locale: string): IssueLoca return (path) => fields(path) ?? actions(path); } if (type === 'flow') return flowNodeLocator(body, locale); + if (type === 'dataset') return datasetMemberLocator(body, locale); return leafIssueLocator(type, body, locale); } diff --git a/ui/packages/data-objectstack/src/metadata-client.references-30064.test.ts b/ui/packages/data-objectstack/src/metadata-client.references-30064.test.ts new file mode 100644 index 00000000000..0b45d50161b --- /dev/null +++ b/ui/packages/data-objectstack/src/metadata-client.references-30064.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#30064 — `MetadataClient.references()` reads the answer + * `GET /meta/:type/:name/references` declares + * (`FindReferencesToMetaResponseSchema`: `{ references: [{ type, name, label?, + * path, kind }] }`, served bare), named from the referring side. + * + * The reader used to take `data.items`, a key the route never serves, so every + * read came back empty and both panels that render it (Setup's References tab, + * Studio's item tools) said "Nothing in the metadata graph points at this item. + * Safe to delete." for every item. An answer the declaration refuses is a + * failed read now, never an empty one. + */ + +import { describe, expect, it } from 'vitest'; +import { FindReferencesToMetaResponseSchema } from '@objectstack/spec/api'; +import { MetadataClient } from './metadata-client'; + +const BASE_URL = 'http://localhost:3000'; + +function clientAnswering(status: number, body: unknown): { client: MetadataClient; urls: string[] } { + const urls: string[] = []; + const client = new MetadataClient({ + baseUrl: BASE_URL, + fetch: (async (input: RequestInfo | URL) => { + urls.push(String(input)); + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch, + }); + return { client, urls }; +} + +/** The route's answer for the CRM dataset, as the server serves it. */ +const SERVED = { + references: [ + { + type: 'dashboard', + name: 'pipeline_dashboard', + label: 'Pipeline Dashboard', + path: 'widgets[].dataset', + kind: 'dashboard dataset', + }, + { type: 'report', name: 'won_by_stage', path: 'dataset', kind: 'report dataset' }, + ], +}; + +describe('MetadataClient.references reads the declared answer (objectstack#30064)', () => { + it('the fixture is the declared shape', () => { + expect(FindReferencesToMetaResponseSchema.safeParse(SERVED).success).toBe(true); + }); + + it('maps each served reference to the referring item, its label kept when served', async () => { + const { client, urls } = clientAnswering(200, SERVED); + const refs = await client.references('dataset', 'opportunity_metrics'); + expect(urls).toEqual([`${BASE_URL}/api/v1/meta/dataset/opportunity_metrics/references`]); + expect(refs).toEqual([ + { + fromType: 'dashboard', + fromName: 'pipeline_dashboard', + fromLabel: 'Pipeline Dashboard', + path: 'widgets[].dataset', + kind: 'dashboard dataset', + value: 'opportunity_metrics', + }, + { fromType: 'report', fromName: 'won_by_stage', path: 'dataset', kind: 'report dataset', value: 'opportunity_metrics' }, + ]); + }); + + it('the honest empty answer reads as no references', async () => { + const { client } = clientAnswering(200, { references: [] }); + expect(await client.references('dataset', 'opportunity_metrics')).toEqual([]); + }); + + it('an answer the declaration refuses is a failed read, never an empty one', async () => { + const { client } = clientAnswering(200, { items: [{ fromType: 'view', fromName: 'all_accounts', path: 'object' }] }); + await expect(client.references('object', 'account')).rejects.toThrow(/declaration refuses/); + }); +}); diff --git a/ui/packages/data-objectstack/src/metadata-client.ts b/ui/packages/data-objectstack/src/metadata-client.ts index e1b333b1340..9480937249c 100644 --- a/ui/packages/data-objectstack/src/metadata-client.ts +++ b/ui/packages/data-objectstack/src/metadata-client.ts @@ -32,7 +32,11 @@ * type, mirroring the framework's "single Zod source per type" rule. */ -import { FlowRegistrationSchema, GetMetaItemLayeredResponseSchema } from '@objectstack/spec/api'; +import { + FindReferencesToMetaResponseSchema, + FlowRegistrationSchema, + GetMetaItemLayeredResponseSchema, +} from '@objectstack/spec/api'; // objectui#8676 - the object-metadata write invariant, applied HERE because this // is a DOOR and not a writer. Every `client.save('object', ...)` call site in the // repo passes through this one method, so guarding it covers a writer set that @@ -769,15 +773,23 @@ export interface MetadataDiagnosticsSummary { stats?: Record; } -/** Reference back-pointer — Phase 3a `/references`. */ +/** + * Reference back-pointer — Phase 3a `/references`: one element of the served + * `references[]` (`FindReferencesToMetaResponseSchema`), named from the + * referring side. + */ export interface MetadataReference { - /** Referencing item's metadata type. */ + /** Referencing item's metadata type (the served `type`). */ fromType: string; - /** Referencing item's name. */ + /** Referencing item's name (the served `name`). */ fromName: string; + /** The referring item's display label, when the server serves one. */ + fromLabel?: string; /** JSON path within the referencing item that holds the reference. */ path: string; - /** The actual value seen at `path` (the referenced name). */ + /** What kind of reference it is (the served `kind`, e.g. `dashboard dataset`). */ + kind?: string; + /** The referenced name: the item the read was asked about. */ value: string; } @@ -1857,8 +1869,21 @@ export class MetadataClient { const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' }); if (res.status === 404) return []; if (!res.ok) throw await parseError(res); - const data = await res.json(); - return Array.isArray(data) ? (data as MetadataReference[]) : (data?.items ?? []); + // The route's declared answer is `{ references: [...] }`, served bare. + // An answer the declaration refuses is a failed read, never "nothing + // references it": the panels render an empty list as safe to delete. + const parsed = FindReferencesToMetaResponseSchema.safeParse(await res.json().catch(() => null)); + if (!parsed.success) { + throw new Error(`The references read for ${type} '${name}' answered a body its declaration refuses.`); + } + return parsed.data.references.map((ref) => ({ + fromType: ref.type, + fromName: ref.name, + ...(ref.label !== undefined ? { fromLabel: ref.label } : {}), + path: ref.path, + kind: ref.kind, + value: name, + })); } /** diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 5a6bac75c02..83e58b5a572 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -1156,6 +1156,11 @@ const STUDIO_EN: Record = { 'engine.studio.refusal.widget': 'the widget “{widget}”', 'engine.studio.refusal.widgetInput': '{input} on the widget “{widget}”', 'engine.studio.refusal.reportInput': '“{input}” in the report’s properties', + // objectstack#30064 — a refused dataset publish, placed on its measure or dimension. + 'engine.studio.refusal.datasetMeasure': 'the measure “{member}”', + 'engine.studio.refusal.datasetMeasureInput': '{input} of the measure “{member}”', + 'engine.studio.refusal.datasetDimension': 'the dimension “{member}”', + 'engine.studio.refusal.datasetDimensionInput': '{input} of the dimension “{member}”', 'engine.studio.refusal.publishIssue': 'Not published. Check {where} — {problem}', 'engine.studio.refusal.publishWhere': 'Not published. Check {where}: Details say what to change.', 'engine.studio.refusal.publishUnlocated': 'Not published: the server refused this draft. Details say why.', @@ -3362,6 +3367,12 @@ const STUDIO_EN: Record = { 'engine.studio.area.formEditor.locale': 'Locale {locale}', 'engine.studio.area.formEditor.oneLocale': 'Only the {locale} version of this template is customized. {package}’s other languages of it stay as they are.', 'engine.studio.area.formEditor.translationWhole': 'Customizing a translation replaces {package}’s whole {locale} bundle: every string in it is then served from your copy, the ones you leave unchanged included.', + // objectstack#30064 — the area's editor of a read-only package's dataset. + 'engine.studio.area.formEditor.datasetWhole': 'Customizing a dataset copies {package}’s whole dataset into your customization: every measure and dimension is then served from your copy, the ones you leave unchanged included.', + 'engine.studio.area.formEditor.usedByOne': 'Once published, a change here reaches the {count} item built on this dataset:', + 'engine.studio.area.formEditor.usedByOther': 'Once published, a change here reaches the {count} items built on this dataset:', + 'engine.studio.area.formEditor.usedByNone': 'Nothing uses this dataset yet: no dashboard, report or other item names it.', + 'engine.studio.area.formEditor.usedByError': 'The dashboards and reports that use this dataset could not be read, so what a change here reaches is not known.', 'engine.studio.publishedAllFlows': 'Published {count} flows', 'engine.studio.publishedAllFlowsOne': 'Published flow "{label}"', 'engine.studio.designer.search': 'Search…', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index f73c481e269..0df6acd7f41 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -1017,6 +1017,11 @@ const STUDIO_ZH: Record = { 'engine.studio.refusal.widget': '组件「{widget}」', 'engine.studio.refusal.widgetInput': '组件「{widget}」的「{input}」', 'engine.studio.refusal.reportInput': '报表属性中的「{input}」', + // objectstack#30064 — 被拒绝发布的数据集,定位到它的度量或维度。 + 'engine.studio.refusal.datasetMeasure': '度量「{member}」', + 'engine.studio.refusal.datasetMeasureInput': '度量「{member}」的「{input}」', + 'engine.studio.refusal.datasetDimension': '维度「{member}」', + 'engine.studio.refusal.datasetDimensionInput': '维度「{member}」的「{input}」', 'engine.studio.refusal.publishIssue': '未发布。请检查{where} —— {problem}', 'engine.studio.refusal.publishWhere': '未发布。请检查{where}:详情中说明了需要修改的内容。', 'engine.studio.refusal.publishUnlocated': '未发布:服务端拒绝了此草稿。详情中说明了原因。', @@ -2961,6 +2966,12 @@ const STUDIO_ZH: Record = { 'engine.studio.area.formEditor.locale': '语言 {locale}', 'engine.studio.area.formEditor.oneLocale': '只定制此模板的 {locale} 版本,{package} 中它的其他语言版本保持不变。', 'engine.studio.area.formEditor.translationWhole': '定制翻译会替换 {package} 的整个 {locale} 语言包:其中每一条文本都改由你的副本提供,包括未修改的条目。', + // objectstack#30064 — 只读软件包的数据集在「全部元数据」中的编辑器。 + 'engine.studio.area.formEditor.datasetWhole': '定制数据集会把 {package} 的整个数据集复制进你的定制:其中每个度量和维度都改由你的副本提供,包括未修改的。', + 'engine.studio.area.formEditor.usedByOne': '发布后,这里的修改会影响基于此数据集的 {count} 项:', + 'engine.studio.area.formEditor.usedByOther': '发布后,这里的修改会影响基于此数据集的 {count} 项:', + 'engine.studio.area.formEditor.usedByNone': '目前没有仪表盘、报表或其他内容使用此数据集。', + 'engine.studio.area.formEditor.usedByError': '无法读取哪些仪表盘和报表在使用此数据集,因此不知道这里的修改会影响什么。', 'engine.studio.publishedAllFlows': '已发布 {count} 个流程', 'engine.studio.publishedAllFlowsOne': '已发布流程「{label}」', 'engine.studio.designer.search': '搜索…',