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
14 changes: 14 additions & 0 deletions .changeset/11862-draft-labels-remainder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/app-shell': patch
---

Studio names a draft-only item by its label, with the machine name as secondary text (objectui#11862, the remainder after objectui#11906).

Since objectstack 18.0.0-next.0 each row of `GET /meta/_drafts` carries the draft body's own label, or `null` when the body declares none (objectstack#22200). Studio now reads that label off the row. It does not read each item to find it:

- **Access:** a new permission set that exists only as a draft reads as its label in the permission-set list, with its machine name beneath it. It used to read as its identifier (`qa_readers`) after a reload.
- **Automations:** a draft-only flow reads as its label in the flow list.
- **Interfaces:** a nav entry that has no label of its own and opens a draft-only object or dashboard reads as that draft's label. This holds in the nav list, on the edit-mode cards and in the Label placeholder of the nav-item inspector. A published label still wins. The inspector's page, dashboard and report pickers offer a draft-only item as `Label (name)`, as they offer a published one.
- **Pending changes:** each row of the list leads with its label, with the machine name beneath it in monospace. This applies to new and updated items alike. A label given in several languages reads in the console language.

A row whose draft declares no label shows its name alone, as before. Display only: nothing that is saved changes. Nothing is added to the package entry: no export, prop or language-pack key.
54 changes: 50 additions & 4 deletions packages/app-shell/src/hooks/useNavTargetLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,22 @@
*
* A target with no label answers `undefined` — the renderer then falls through
* the spec's ladder and, at the bottom, to the target's machine name.
*
* ## A designer's pending drafts — the last rung (objectui#11862)
*
* A draft-only object or dashboard is in no published list, so the metadata
* above cannot name it, and a label-less entry that opens one read as its
* machine name in the Studio's nav editor. Where a designer holds its
* package's pending drafts, it hands them to {@link DraftTargetLabels}, and the
* label a draft's `_drafts` row carries (objectstack-ai/objectstack#22200) is
* asked last: only when the metadata has no label for the target, so a
* published label still wins. Nothing is fetched for it — the rows are the
* ones the designer already read. Without a provider the list is empty and
* this hook answers exactly as before, which is the console's case.
*/

import { useMemo } from 'react';
import { createContext, useContext, useMemo } from 'react';
import { canonicalMetaUrlType } from '@objectstack/spec/shared';
import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui';
import { resolveViewId } from '@object-ui/core';
import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
Expand Down Expand Up @@ -83,6 +96,36 @@ export function findByName(items: readonly unknown[] | undefined, name: string):
);
}

/**
* A pending draft as a designer holds it: the type its row is stored under,
* its name, and the label its `_drafts` row carries (absent or `null` when the
* draft body declares none).
*/
export interface DraftTargetLabel {
type: string;
name: string;
label?: unknown;
}

/**
* The pending drafts a designer holds, for the last rung above. Internal to
* app-shell: the Studio surface provides the `_drafts` rows it reads for its
* Changes count; nothing else does.
*/
export const DraftTargetLabels = createContext<ReadonlyArray<DraftTargetLabel>>([]);

/** The label of the pending draft of `type` named `name`, as display text, or `undefined`. */
function draftText(
drafts: ReadonlyArray<DraftTargetLabel>,
type: string,
name: string,
language: string | undefined,
): string | undefined {
// A stored type spelling is folded as every `/meta` reader folds it.
const draft = drafts.find((d) => d.name === name && canonicalMetaUrlType(d.type) === type);
return draft ? metadataText(draft.label, language) : undefined;
}

/** An object's merged `listViews` map (`<object>.<key>` → view), or `{}` when it has none. */
function listViewsOf(def: LabelledRecord | undefined): Record<string, LabelledRecord | undefined> {
const views = def?.listViews;
Expand All @@ -93,13 +136,15 @@ export function useNavTargetLabel(): NavTargetLabelResolver {
const metadata = useMetadata();
const { objectLabel, viewLabel, dashboardLabel } = useObjectLabel();
const { language } = useObjectTranslation();
const drafts = useContext(DraftTargetLabels);

return useMemo<NavTargetLabelResolver>(
() => (target: NavLabelTarget) => {
switch (target.kind) {
case 'object': {
const def = findByName(metadata.objects, target.objectName);
const text = metadataText(def?.label, language);
const text =
metadataText(def?.label, language) ?? draftText(drafts, 'object', target.objectName, language);
return nonEmpty(objectLabel({ name: target.objectName, label: text ?? '' }));
}
case 'view': {
Expand All @@ -110,13 +155,14 @@ export function useNavTargetLabel(): NavTargetLabelResolver {
}
case 'dashboard': {
const def = findByName(metadata.dashboards, target.dashboardName);
const text = metadataText(def?.label, language);
const text =
metadataText(def?.label, language) ?? draftText(drafts, 'dashboard', target.dashboardName, language);
return nonEmpty(dashboardLabel({ name: target.dashboardName, label: text ?? '' }));
}
default:
return undefined;
}
},
[metadata, objectLabel, viewLabel, dashboardLabel, language],
[metadata, objectLabel, viewLabel, dashboardLabel, language, drafts],
);
}
31 changes: 29 additions & 2 deletions packages/app-shell/src/preview/DraftChangesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import { useObjectTranslation } from '@object-ui/i18n';
// refuses, and a faithful copy is exactly the fork that guard exists to prevent.
import { fetchPendingDrafts } from './usePendingDrafts.js';
import { canonicalMetaUrlType } from '@objectstack/spec/shared';
import { resolveI18nLabel, type I18nLabel } from '@objectstack/spec/ui';
import { stripReadDecorations } from '@objectstack/spec/kernel';
import { diffFields } from '../views/metadata-admin/previews/object-fields-io.js';
// The live `?surface=` channel, and NOT `useSurfaceDeepLink` beside it: this
Expand Down Expand Up @@ -100,6 +101,13 @@ export interface DraftChangeEntry {
* two rows, each with its own reads; an entry without one sends no selector.
*/
member?: string;
/**
* The label this draft's `_drafts` row carries (objectui#11862): the draft
* body's own label, read off the row (objectstack-ai/objectstack#22200).
* Absent when the body declares none. The row leads with it and shows the
* name beneath it; with none, the name alone is the row's text.
*/
label?: I18nLabel;
/** `new` = no published version; `update` = overwrites one; undefined = probing. */
kind?: 'new' | 'update';
}
Expand Down Expand Up @@ -133,6 +141,7 @@ async function listPendingDrafts(packageId?: string | null): Promise<DraftChange
name: d.name,
packageId: d.packageId,
...(d.member ? { member: d.member } : {}),
...(d.label !== undefined ? { label: d.label } : {}),
}));
}

Expand Down Expand Up @@ -472,7 +481,7 @@ export function DraftChangesPanel({
onPublish,
publishing = false,
}: DraftChangesPanelProps) {
const { t } = useObjectTranslation();
const { t, language } = useObjectTranslation();
const [entries, setEntries] = useState<DraftChangeEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
/**
Expand Down Expand Up @@ -685,6 +694,13 @@ export function DraftChangesPanel({
// objectui#11807 — the drill-in found nothing this publish
// changes: not an UPDATE, whatever the name list said.
const isUnchanged = unchanged.has(key);
// objectui#11862 — the row's label leads, in the console
// language, for a NEW row and an UPDATE row alike: the one
// `_drafts` serves, so this list never chooses between a
// published and a draft label. The name follows as
// secondary text, or is the row's text when there is no
// label.
const labelText = resolveI18nLabel(entry.label, language)?.trim() ?? '';
return (
<li key={key} className="rounded-md border text-sm">
<button
Expand Down Expand Up @@ -716,7 +732,18 @@ export function DraftChangesPanel({
) : (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate font-mono text-xs">{entry.name}</span>
{labelText ? (
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{labelText}</span>
{labelText !== entry.name ? (
<span className="truncate font-mono text-[11px] leading-tight text-muted-foreground">
{entry.name}
</span>
) : null}
</span>
) : (
<span className="min-w-0 flex-1 truncate font-mono text-xs">{entry.name}</span>
)}
{entry.member ? (
// The member is what tells two rows of one name
// apart (objectstack#23073); shown as served.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#11862 (the remainder) — the Changes list leads each row with the
* item's label, and the machine name follows as secondary text.
*
* Measured on a live boot (re-measure `6138292486`): rows read
* `PERMISSION · 1 | qa_readers | New` and `OBJECT · 1 | laneawf_qa_wf_obj |
* Update`, where the object's label is "QA WF Object 2". On the maintainer's
* word 「所有地方以标签为主,机器名只作为次要信息」 and the seat ruling `6053526025`
* (question 1, A at the producer), the label is the one each `_drafts` row
* carries since objectstack-ai/objectstack#22200: the draft body's own label,
* or `null`. It is read off that row, for a NEW row and an UPDATE row alike,
* and no item is read to find it.
*
* - a NEW row and an UPDATE row lead with the row's label, the machine name
* beneath it in monospace;
* - a locale-map label reads in the console language;
* - CONTROL: a row whose label is `null` reads as its name alone, once, in
* monospace, as before;
* - no item is read for a label: before any row is expanded, a permission
* or page row is never read by name. (An object row's draft IS read at
* open, by the security-posture lint, for its sharing settings: that read
* predates this change and is not a label read.)
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k,
language: 'zh-CN',
}),
}));

import { DraftChangesPanel } from '../DraftChangesPanel';

const DRAFT_ROWS = [
{ type: 'permission', name: 'qa_readers', packageId: 'com.x', label: 'QA Readers' },
{ type: 'object', name: 'laneawf_qa_wf_obj', packageId: 'com.x', label: 'QA WF Object 2' },
{ type: 'page', name: 'home', packageId: 'com.x', label: { en: 'Home', 'zh-CN': '首页' } },
{ type: 'object', name: 'bare_obj', packageId: 'com.x', label: null },
];

const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
if (url.includes('/_drafts')) return ok({ drafts: DRAFT_ROWS });
if (/\/meta\/object(\?|$)/.test(url)) return ok([{ name: 'laneawf_qa_wf_obj', label: 'QA WF Object' }]);
if (/\/meta\/page(\?|$)/.test(url)) return ok([]);
if (/\/meta\/permission(\?|$)/.test(url)) return ok([]);
return { ok: false, status: 404, json: async () => ({}) };
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
fetchMock.mockClear();
});

async function renderPanel() {
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch);
render(<DraftChangesPanel open onOpenChange={() => {}} packageId="com.x" />);
// Every row classified: each badge has replaced its spinner.
await waitFor(() => expect(screen.getAllByText(/^(New|Update)$/)).toHaveLength(DRAFT_ROWS.length));
}

/** The row toggle that holds `text`. */
function rowOf(text: string): HTMLElement {
return screen.getByText(text).closest('[data-testid="draft-entry-toggle"]') as HTMLElement;
}

describe('objectui#11862 — the Changes list: label first, machine name second', () => {
it("a NEW row leads with its `_drafts` row's label, the machine name beneath it", async () => {
await renderPanel();
const row = rowOf('QA Readers');
expect(within(row).getByText('qa_readers')).toHaveClass('font-mono');
expect(within(row).getByText('New')).toBeInTheDocument();
});

it("an UPDATE row leads with its `_drafts` row's label, the machine name beneath it", async () => {
await renderPanel();
const row = rowOf('QA WF Object 2');
expect(within(row).getByText('laneawf_qa_wf_obj')).toHaveClass('font-mono');
expect(within(row).getByText('Update')).toBeInTheDocument();
});

it('a locale-map label reads in the console language, never as [object Object]', async () => {
await renderPanel();
const row = rowOf('首页');
expect(within(row).getByText('home')).toHaveClass('font-mono');
expect(screen.queryByText('[object Object]')).toBeNull();
});

it('CONTROL: a row whose label is null reads as its name alone, once, in monospace', async () => {
await renderPanel();
const row = rowOf('bare_obj');
expect(within(row).getAllByText('bare_obj')).toHaveLength(1);
expect(within(row).getByText('bare_obj')).toHaveClass('font-mono');
});

it('no item is read for a label: a permission or page row is never read by name', async () => {
await renderPanel();
const urls = fetchMock.mock.calls.map(([input]) => String(input));
expect(urls.some((u) => u.includes('/_drafts'))).toBe(true);
// An item read is `/meta/TYPE/NAME`; none is made for these rows before one is expanded.
expect(urls.filter((u) => /\/meta\/(permission|page)\/[^/?]+/.test(u))).toEqual([]);
});
});
42 changes: 34 additions & 8 deletions packages/app-shell/src/preview/usePendingDrafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
*/

import { useCallback, useEffect, useRef, useState } from 'react';
import type { I18nLabel } from '@objectstack/spec/ui';
import { subscribeMetadataRefresh } from '../assistant/assistantBus.js';

export interface PendingDraftEntry {
Expand All @@ -48,6 +49,26 @@ export interface PendingDraftEntry {
* as `?member=`. Copied as served, never derived from the name or the body.
*/
member?: string;
/**
* The draft body's own label, as `_drafts` served it
* (objectstack-ai/objectstack#22200, `ListDraftsResponseSchema`
* `drafts[].label`): a plain string, or on the UI types an inline locale map
* the reader resolves (`resolveI18nLabel`). Absent when the row's label is
* `null` — the producer's answer for a body that declares none — so a caller
* shows the name only where there is truly no label (objectui#11862). Copied
* as served; never a fallback to the name.
*/
label?: I18nLabel;
}

/**
* A `_drafts` row's `label`, as served: a non-blank string or a locale map,
* else `undefined` (`null` and anything else).
*/
function servedLabel(raw: unknown): I18nLabel | undefined {
if (typeof raw === 'string') return raw.trim() ? raw : undefined;
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw as I18nLabel;
return undefined;
}

/**
Expand Down Expand Up @@ -129,14 +150,19 @@ export async function fetchPendingDrafts(
const list = Array.isArray(data) ? data : (data?.drafts ?? data?.data?.drafts ?? []);
return (Array.isArray(list) ? list : [])
.filter((d) => typeof d?.type === 'string' && typeof d?.name === 'string')
.map((d) => ({
type: d.type as string,
name: d.name as string,
packageId: typeof d.packageId === 'string' && d.packageId ? (d.packageId as string) : null,
// Only when served: an entry without `member` stays without one, so no
// caller ever sends an empty `?member=` (a blank selector is a 400).
...(typeof d.member === 'string' && d.member ? { member: d.member as string } : {}),
}));
.map((d) => {
// objectui#11862 — the label, read off the row it arrived on.
const label = servedLabel(d.label);
return {
type: d.type as string,
name: d.name as string,
packageId: typeof d.packageId === 'string' && d.packageId ? (d.packageId as string) : null,
// Only when served: an entry without `member` stays without one, so no
// caller ever sends an empty `?member=` (a blank selector is a 400).
...(typeof d.member === 'string' && d.member ? { member: d.member as string } : {}),
...(label !== undefined ? { label } : {}),
};
});
}

export interface UsePendingDraftsOptions {
Expand Down
Loading
Loading