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
21 changes: 21 additions & 0 deletions .changeset/28562-studio-customize-read-only-base.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

feat(studio): customize a read-only package's view, dashboard or report into its `<base>.custom` pair, with an override badge and "Restore package original"

Clause-②: no
Metadata-protocol: none

An installed package is read-only in Studio, so until now its views, dashboards and reports could only be looked at there. Studio now customizes them into the base's paired customization package (`<base>.custom`, ADR-0146 D2 and D3), as a declared override of the base's item.

- **Customize.** The item header of a view, dashboard or report on a read-only base, and of an object leaf's list view, offers Customize. Pressing it only unlocks the item's editors. Nothing is written until an edit is saved.
- **The first saved edit.** It calls `POST /api/v1/packages/:id/ensure-customization` once for that item, with no prompt and no body. It then saves the draft into the package the answer names (`201` when the door created the pair, `200` when it was there). The draft is the item as Studio holds it, without its `_` keys, with `overrides: { package, basedOn }`. `basedOn` is the base's `manifest.version` as the package list serves it. ⛔ The pair's id for a write is the door's answer, never a `.custom` spelled in the browser. The first save is pinned to "no draft there" (`If-None-Match: *`), so a draft someone else left in the pair is asked about, never overwritten. A base whose listed manifest states no version gets a disabled Customize that says so. Nothing is guessed.
- **Walled callers.** Under a `group` or `isolated` posture, an admin who is not the platform operator sees Customize disabled, with the reason. This is read from the two facts the server publishes (`features.tenancyPosture`, `user.isPlatformAdmin`). A door that refuses anyway is shown as the server sent it.
- **Pending and Publish.** On a read-only base with a pair, the pending count, the changes panel and Publish act on the pair's drafts. A refusal at the publish door is shown as it is sent. A read-only base with no pair keeps the read-only Publish, as before.
- **Badge.** The header says the item overrides the base (for example "Overrides HotCRM", with the version in its title). It is read from the declaration the server serves: the pair's draft, else the layered read's overlay. It is never computed.
- **Restore package original.** The item's ⋯ menu offers it on an item customized into the pair. After a confirmation, it deletes the pair's draft of the item, then its row (`DELETE /meta/:type/:name?package=<pair>`). The base's item reads back.
- **The read-only mark** no longer links a view, dashboard or report to Setup's overlay page.
- **Unchanged.** A writable package is edited in place, as before. A new view ("Add view") on a read-only base stays unavailable. The item tools (history, source) stay read-only on the base.
- **New strings.** `@object-ui/i18n`'s studio namespace gains twelve `engine.studio.customize.*` keys in `en` and `zh`. The other languages fall back to `en`, as the whole namespace does.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,28 @@ export type NewViewPayload = Parameters<CreateViewDialogProps['onCreate']>[0];
/** Why "Add view" saved nothing: a view already holds the name, or the save failed. */
export type ViewCreateRefusal = { taken: string } | { error: unknown };

/**
* objectstack#28562 (ADR-0146 D2) — where a read-only base's list view is
* customized: read in the base's paired customization package, written there
* as an override of the base's view. The Interfaces pillar passes it on a
* read-only base only; without it the view is read and written at `packageId`.
*/
export interface ListViewCustomization {
/**
* The package the view's draft is read in when a load runs: the pair when
* the package list lists one, else `null` (the view is then read at
* `packageId`, where no pair draft can be). Asked at load time, so a pair
* the first save creates is never a reason to reload the buffer it saved.
*/
readAt: () => string | null;
/**
* The write: the package the save addresses (the ensure door's answer) and
* the body it sends (the view, declaring the override). Rejects with the
* door's refusal, and then nothing is saved.
*/
writeTarget: (viewId: string, row: Row) => Promise<{ packageId: string; body: Row }>;
}

/**
* The open leaf's list-view buffer, held by the Interfaces pillar so the
* canvas and the panel read one copy (the folded layout unmounts the panel
Expand All @@ -391,6 +413,7 @@ export function useObjectListViewDraft({
publishNonce,
draftOnlyObjects,
onDraftSaved,
customization = null,
}: {
client: MetadataClient;
packageId: string;
Expand All @@ -403,6 +426,12 @@ export function useObjectListViewDraft({
*/
draftOnlyObjects?: DraftOnlyObjects | null;
onDraftSaved?: () => void;
/**
* objectstack#28562 — on a read-only base, where the view is customized
* ({@link ListViewCustomization}). Read through a ref: only its presence is
* a reason to load again.
*/
customization?: ListViewCustomization | null;
}): ObjectListViewDraft {
const metadata = useMetadata();
const { user, activeOrganization } = useAuth();
Expand Down Expand Up @@ -494,6 +523,16 @@ export function useObjectListViewDraft({
React.useEffect(() => {
draftOnlyRef.current = draftOnlyObjects;
}, [draftOnlyObjects]);
// objectstack#28562 — read when a load or a save runs; `customizing` is
// the one fact of it a load is keyed on.
const customizationRef = React.useRef(customization);
React.useLayoutEffect(() => {
customizationRef.current = customization;
});
const customizing = customization !== null;
// objectstack#28562 — the view and package the installed buffer's draft
// read was asked at: the address its pin describes.
const readAtRef = React.useRef<{ viewId: string; at: string } | null>(null);

React.useEffect(() => {
setFailure(null);
Expand All @@ -505,8 +544,10 @@ export function useObjectListViewDraft({
(async () => {
try {
// objectui#11773 — read at the address the save below writes, so the
// version it serves pins that save.
const draftRead = await readDraftForSave<Row>(client, 'view', viewId, packageId);
// version it serves pins that save. objectstack#28562 — on a
// read-only base that is the pair, when one is listed.
const readAt = customizationRef.current?.readAt() ?? packageId;
const draftRead = await readDraftForSave<Row>(client, 'view', viewId, readAt);
if (cancelled) return;
const draftResp = draftRead.answer;
// A served draft is the whole document, taken as-is (objectui#10765);
Expand Down Expand Up @@ -558,7 +599,8 @@ export function useObjectListViewDraft({
? publishedRow
: undefined;
// objectui#11773 — the next save is pinned to what the draft read served.
recordRead('view', viewId, packageId, draftRead.pin);
recordRead('view', viewId, readAt, draftRead.pin);
readAtRef.current = { viewId, at: readAt };
setBuffer({
for: listViewBufferKey(viewId),
row,
Expand All @@ -574,7 +616,7 @@ export function useObjectListViewDraft({
return () => {
cancelled = true;
};
}, [client, viewId, objectName, packageId, publishNonce, reloadNonce, recordRead]);
}, [client, viewId, objectName, packageId, publishNonce, reloadNonce, recordRead, customizing]);

const edit = React.useCallback((row: Row) => {
setBuffer((b) => ({ ...b, row, dirty: true }));
Expand Down Expand Up @@ -618,7 +660,24 @@ export function useObjectListViewDraft({
if (!viewId || !row || notList) return;
setSaving(true);
try {
const outcome = await guardedSave('view', viewId, row, { mode: 'draft', packageId, surface: 'studio' });
// objectstack#28562 — a customization is written where the ensure
// door says, with its declaration; a refused door saves nothing.
const custom = customizationRef.current;
const target = custom ? await custom.writeTarget(viewId, row) : { packageId, body: row };
// The buffer was read at another package (the base, before its pair
// was listed): the pair is taken to hold no draft of the view, so a
// draft there is a conflict the author is asked about, never one
// this save overwrites. Its receipt pins the saves after it.
const readAt = readAtRef.current;
if (custom && !(readAt?.viewId === viewId && readAt.at === target.packageId)) {
recordRead('view', viewId, target.packageId, { ifNoneMatch: '*' });
readAtRef.current = { viewId, at: target.packageId };
}
const outcome = await guardedSave('view', viewId, target.body, {
mode: 'draft',
packageId: target.packageId,
surface: 'studio',
});
// objectui#11773 — the author chose the saved version; the load replaces the buffer.
if (outcome === 'reloaded') return;
// objectui#11204 — clean only if nothing was edited while it was in flight.
Expand All @@ -632,7 +691,7 @@ export function useObjectListViewDraft({
setSaving(false);
}
},
[guardedSave, viewId, row, notList, packageId, onDraftSaved],
[guardedSave, recordRead, viewId, row, notList, packageId, onDraftSaved],
);

const current = buffer.for === targetKey && targetKey !== '';
Expand Down Expand Up @@ -1057,6 +1116,7 @@ function KanbanCardSection({
export function ObjectListViewInspector({
listView,
readOnly,
canCreate = !readOnly,
locale,
selection,
onSelectionChange,
Expand All @@ -1065,6 +1125,12 @@ export function ObjectListViewInspector({
}: {
listView: ObjectListViewDraft;
readOnly: boolean;
/**
* objectstack#28562 — whether "Add view" is offered; `!readOnly` by default.
* A read-only base's view is customized in its pair, while a new view would
* be a new item in the base, which it does not take.
*/
canCreate?: boolean;
locale: string;
/**
* objectstack#27836 — called as "Add view" opens its dialog: the host sends
Expand Down Expand Up @@ -1320,7 +1386,7 @@ export function ObjectListViewInspector({
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
disabled={readOnly || fieldState.loading || !!fieldState.error}
disabled={!canCreate || fieldState.loading || !!fieldState.error}
onClick={() => {
onAddViewOpen?.();
setAddOpen(true);
Expand Down
Loading
Loading