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
18 changes: 18 additions & 0 deletions .changeset/29786-studio-contributed-items-greyed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

feat(studio): draw the nav entries, fields and flows other packages contribute to a base greyed, with the contributing package

Clause-②: no
Metadata-protocol: none

Studio now draws on a read-only base the items other installed packages contribute to it. Before, it drew none of them, while the live app showed them all. The Interfaces tree of a base said "No items in this package" for a group that only other packages fill.

- **One reader.** Everything comes from the `contributions` of the base's customization read (`GET /api/v1/packages/:id/customizations`), the read the rails' marks already make. No second request is sent. Nothing is joined on names in the browser. The contributing package is named as the base is, by its id, from the package list.
- **Interfaces.** Each `nav` entry for the open app draws in its tree, greyed and labelled "from PACKAGE". It draws under the group it names when the app declares that group (`type: 'group'`, at any depth: the question the server's navigation fold asks), and at the top level otherwise. An entry naming a group the app does not declare says so in its tooltip. A declared group that holds only contributed entries no longer says "No items in this package". The served entry names the item's id and no label, so the row reads the id. A contributed entry is no nav entry of the base: it is never in the nav edit buffer and cannot be dragged, renamed or deleted. A click opens the contributing package's Interfaces pillar.
- **Data.** A contributed field is already served on the base's object (the engine merges every extension). The Form layout now marks its card, greyed with "from PACKAGE", and never as a drag source. Its properties open read-only, even where the pair could write a base field's display properties.
- **Automations.** Each contributed `flow` draws in its object's rail group, greyed with "from PACKAGE". An object that holds no flow of the base's gets a group of its own. A click opens the flow in the contributing package's Automations pillar.
- **Unchanged.** A writable package, a base whose read lists no contributions, and the contributing package's own surfaces draw as before.
- **New strings.** `@object-ui/i18n`'s studio namespace gains five keys in `en` and `zh` (`engine.studio.contributed.from`, `.navTitle`, `.fieldTitle`, `.flowTitle`, `.undeclaredGroup`). The other languages fall back to `en`, as the whole namespace does.
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,22 @@ export type PackageCustomizationsState =
index: CustomizationMarkIndex;
/** The base's listed name, else its id. */
baseName: string;
/**
* objectstack#29786 — each contributing package's listed name, by the id
* a `contributions` entry names; a package the list cannot name is absent
* and its id stands in.
*/
contributorNames: ReadonlyMap<string, string>;
};

/**
* Read `baseId`'s customizations, or nothing (`off`) when `baseId` is `null` or
* derives no pair: a customization package itself has none to read, and the
* route refuses its id.
*
* The base's name comes from the package list the area already reads (one
* request, `packages-io.ts`); when that list cannot name it, the id stands in.
* The base's name, and each contributing package's (objectstack#29786), come
* from the package list the area already reads (one request, `packages-io.ts`);
* when that list cannot name one, its id stands in.
*
* `refresh` (objectstack#28572): read again whenever it moves (Studio's
* publish, which is what changes the served overrides). A re-read keeps the
Expand All @@ -138,15 +145,23 @@ export function usePackageCustomizations(baseId: string | null, refresh: unknown
React.useEffect(() => {
if (!readable || baseId === null) return;
let cancelled = false;
const name = fetchListedPackageManifests().then(
(manifests) => manifests.get(baseId)?.name ?? baseId,
() => baseId,
);
Promise.all([fetchPackageCustomizations(baseId), name]).then(
([read, baseName]) => {
const listed = fetchListedPackageManifests().catch(() => null);
Promise.all([fetchPackageCustomizations(baseId), listed]).then(
([read, manifests]) => {
if (cancelled) return;
const index = indexCustomizations(baseId, read);
setAnswer({ id: baseId, state: index ? { status: 'read', read, index, baseName } : { status: 'off' } });
const baseName = manifests?.get(baseId)?.name ?? baseId;
// objectstack#29786 — the contributing packages are named as the base
// is: by the id, from the same package list.
const contributorNames = new Map<string, string>();
for (const c of read.contributions) {
const name = manifests?.get(c.packageId)?.name;
if (name) contributorNames.set(c.packageId, name);
}
setAnswer({
id: baseId,
state: index ? { status: 'read', read, index, baseName, contributorNames } : { status: 'off' },
});
},
(err: unknown) => {
if (cancelled) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ import { formDndAccessibility, type FormDndLookups, type FormDndSlot } from './f
import { AddFieldPopover } from './AddFieldPopover.js';
import type { FieldTypeId } from '../metadata-admin/previews/field-types.js';
import { fieldTypeName } from '../metadata-admin/previews/fieldTypeName.js';
import { ContributionMark, contributedItemTitle } from './StudioRailCustomizations.js';
import type { ContributedOnObject } from './contributionRows.js';

const UNGROUPED = '__ungrouped__';

Expand Down Expand Up @@ -311,8 +313,18 @@ export interface ObjectFormDesignerProps {
* toolbar so the Form tab keeps one toolbar row (objectui#12451).
*/
modeControl?: React.ReactNode;
/**
* objectstack#29786 — the object's fields another package contributes, by
* name (`contributedFieldsOf`): their cards are greyed, name the package, and
* are never dragged. A click still selects (the inspector shows them
* read-only).
*/
contributedFields?: ReadonlyMap<string, ContributedOnObject>;
}

const NO_CONTRIBUTED_FIELDS: ReadonlyMap<string, ContributedOnObject> = new Map();
const ContributedFieldsContext = React.createContext<ReadonlyMap<string, ContributedOnObject>>(NO_CONTRIBUTED_FIELDS);

/**
* The Form tab's one toolbar row (objectui#12451): the host's mode control and
* a hint at the left, the actions at the right. The designer draws it in Layout;
Expand Down Expand Up @@ -415,13 +427,17 @@ function SortableField({
readOnly?: boolean;
}): React.ReactElement {
const locale = useMetadataLocale();
// objectstack#29786 — a field another package contributes reads as a
// read-only card on any surface, and names its package.
const contributed = React.useContext(ContributedFieldsContext).get(entry.name) ?? null;
const locked = readOnly || contributed !== null;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: fid(entry.name),
// objectui#11872 — the role a screen reader announces for the card, in the
// author's locale; left unset, dnd-kit writes its English `sortable`. A
// read-only card is not draggable, so it gets none: ARIA does not expose a
// blank `aria-roledescription`, and the card reads as the button it is.
attributes: { roleDescription: readOnly ? '' : t('engine.studio.designer.fieldRole', locale) },
attributes: { roleDescription: locked ? '' : t('engine.studio.designer.fieldRole', locale) },
});
const type = String(entry.def.type ?? 'text');
const required = !!entry.def.required;
Expand All @@ -441,27 +457,39 @@ function SortableField({
style={{ transform: CSS.Transform.toString(transform), transition }}
onClick={onSelect}
{...attributes}
{...listeners}
// objectstack#29786 — a contributed card takes no drag listener: it is
// never a drag source. Not `useSortable`'s `disabled`, which would mark
// it `aria-disabled` while a click still selects it.
{...(contributed ? {} : listeners)}
// objectui#11924 — dnd-kit points every card's `aria-describedby` at the
// canvas's drag instructions, and `useSortable` cannot take it away
// (`disabled` keeps it and adds `aria-disabled`, though a click still
// selects). A read-only card cannot be dragged, so it names none.
aria-describedby={readOnly ? undefined : attributes['aria-describedby']}
aria-describedby={locked ? undefined : attributes['aria-describedby']}
aria-label={
readOnly
locked
? tFormat('engine.studio.designer.fieldAriaReadOnly', locale, { label })
: tFormat('engine.studio.designer.fieldAria', locale, { label })
}
// objectui#12453 — what a click still does on a read-only package, the
// half of the retired toolbar hint that was not "read-only".
title={readOnly ? t('engine.studio.designer.fieldTitleReadOnly', locale) : undefined}
title={
contributed
? contributedItemTitle(contributed, locale)
: readOnly
? t('engine.studio.designer.fieldTitleReadOnly', locale)
: undefined
}
data-contributed={contributed ? 'field' : undefined}
data-package={contributed?.packageId}
// objectui#12451 — a field card: a transparent border that turns muted on
// hover; selected, a primary border with a soft primary ring, and the
// type tag turns primary.
className={cn(
'flex touch-none select-none flex-col gap-2 rounded-md border px-3 py-2.5',
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing',
locked ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing',
spanFull && 'col-span-full',
contributed && 'text-muted-foreground opacity-70',
selected ? 'border-primary bg-background ring-[3px] ring-primary/10' : 'border-transparent hover:border-border',
isDragging && 'opacity-40',
)}
Expand All @@ -471,6 +499,9 @@ function SortableField({
<span className="truncate">{label}</span>
{required && <span className="text-destructive">*</span>}
</span>
{contributed && (
<ContributionMark packageName={contributed.packageName} packageId={contributed.packageId} locale={locale} />
)}
<span
className={cn(
'shrink-0 text-[10px] font-semibold uppercase tracking-wider',
Expand Down Expand Up @@ -672,6 +703,7 @@ export function ObjectFormDesigner({
onSelectGroup,
readOnly = false,
modeControl,
contributedFields = NO_CONTRIBUTED_FIELDS,
}: ObjectFormDesignerProps): React.ReactElement {
const locale = useMetadataLocale();
const view = React.useMemo(() => readFields(draft.fields), [draft.fields]);
Expand Down Expand Up @@ -893,6 +925,7 @@ export function ObjectFormDesigner({
const activeEntry = activeId && !activeId.startsWith('g:') ? entryByName.get(unFid(activeId)) : null;

return (
<ContributedFieldsContext.Provider value={contributedFields}>
<div className="flex min-h-0 flex-1 flex-col">
<FormToolbar
leading={modeControl}
Expand Down Expand Up @@ -991,5 +1024,6 @@ export function ObjectFormDesigner({
</DndContext>
</div>
</div>
</ContributedFieldsContext.Provider>
);
}
Loading
Loading