diff --git a/.changeset/29009-list-lock-without-type.md b/.changeset/29009-list-lock-without-type.md new file mode 100644 index 00000000000..5f51c55c523 --- /dev/null +++ b/.changeset/29009-list-lock-without-type.md @@ -0,0 +1,13 @@ +--- +'@object-ui/plugin-list': patch +--- + +fix(plugin-list): a list view whose `sharing` sets `lockedBy` and no `type` shows its lock indicator + +Clause-②: no +Metadata-protocol: none + +The list toolbar drew its read-only sharing indicator only when the view declared `sharing.type`. `ViewSharingSchema` defaults `type` to `collaborative`, and the served view carries the authored body without that default, so a view authored as `sharing: { lockedBy: 'admin@objectos.ai' }` showed no lock: other readers did not learn the view was locked. + +- **Now.** The indicator draws when `sharing` declares a `type` or a `lockedBy`; the lock keys on `lockedBy` alone. An absent `type` shows the spec's default type label ("Shared view"), read from `ViewSharingSchema` rather than restated in the renderer. +- **Unchanged.** A view with `type` set renders as before, a `sharing` block with neither key shows nothing, and no key, value or copy changes. diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index 469d4e3fa77..ccc95a10c76 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -42,7 +42,7 @@ import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLoc // objectui's keyed `{ key, defaultValue, params }` ref — that vocabulary lives // on the FLAT `schema.ariaLabel` and is resolved by `SchemaRenderer` instead // (objectui#5134). -import { resolveI18nLabel as resolveInlineI18nLabel, normalizeFilterOperator, PaginationConfigSchema } from '@objectstack/spec/ui'; +import { resolveI18nLabel as resolveInlineI18nLabel, normalizeFilterOperator, PaginationConfigSchema, ViewSharingSchema } from '@objectstack/spec/ui'; import type { GroupingConfig } from '@objectstack/spec/ui'; import { resolveInjectedSystemColumns, resolveSearchFieldResolution, type SearchFieldMeta } from '@objectstack/spec/data'; import { SystemFieldName } from '@objectstack/spec/system'; @@ -1311,6 +1311,23 @@ function readSpecDisplayPageSize(): number { } const DEFAULT_LIST_DISPLAY_PAGE_SIZE = readSpecDisplayPageSize(); +/** + * The ownership type a `sharing` block means when it declares none. READ from + * `@objectstack/spec`, not restated, the same read as the page size above: + * `ViewSharingSchema` declares `type` with a default, nothing on the render + * path parses the served view, so an authored `{ lockedBy }` arrives with + * `type` absent, and parsing an empty block is the spec's own answer. + */ +const SPEC_DEFAULT_SHARING_TYPE = ViewSharingSchema.parse({}).type; + +/** + * Whether the toolbar draws the sharing indicator: a declared `type` or a + * `lockedBy`. The lock keys on `lockedBy` alone and never waits on `type`. + */ +function hasSharingIndicator(sharing: { type?: string; lockedBy?: string } | undefined): boolean { + return !!(sharing?.type || sharing?.lockedBy); +} + /** * The `$top` of the ONE unpaged fetch a list view makes when no usable page * size is declared: every view this component does not page on the server @@ -6454,12 +6471,15 @@ export const ListView = React.forwardRef(({ (`type` + optional `lockedBy`), never a button. No view-sharing control or endpoint exists to open, so a clickable "Share" here was a dead button. `lockedBy` is the spec's free string ("User - who locked the view configuration"), rendered as written. */} - {schema.sharing?.type && (() => { - const sharingLabel = schema.sharing.type === 'personal' + who locked the view configuration"), rendered as written. A + block with `lockedBy` and no `type` shows the lock beside the + spec's default type. */} + {hasSharingIndicator(schema.sharing) && (() => { + const sharingType = schema.sharing?.type ?? SPEC_DEFAULT_SHARING_TYPE; + const sharingLabel = sharingType === 'personal' ? t('list.sharingPersonal') : t('list.sharingCollaborative'); - const lockedBy = schema.sharing.lockedBy; + const lockedBy = schema.sharing?.lockedBy; const lockedLabel = lockedBy ? t('list.sharingLockedBy', { user: lockedBy }) : undefined; const hint = lockedLabel ? `${sharingLabel} · ${lockedLabel}` : sharingLabel; return ( @@ -6508,7 +6528,7 @@ export const ListView = React.forwardRef(({ {/* --- Separator: Print/Share/Export | Search --- */} {(() => { - const hasLeftSideItems = schema.allowPrinting || !!schema.sharing?.type + const hasLeftSideItems = schema.allowPrinting || hasSharingIndicator(schema.sharing) || (!toolbarFlags.compactToolbar && resolvedExportOptions && exportPermitted && exportableFormats.length > 0); return toolbarFlags.showSearch && hasLeftSideItems ? (
diff --git a/ui/packages/plugin-list/src/__tests__/ListView.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.test.tsx index 1992351172e..517e5113354 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.test.tsx @@ -2644,6 +2644,41 @@ describe('ListView', () => { expect(indicator).not.toHaveAttribute('role'); expect(indicator).not.toHaveAttribute('tabindex'); }); + + // The lock keys on `lockedBy`, never on `type`: the spec defaults `type` + // to `collaborative` and the served view carries the authored body, so a + // view that writes only `lockedBy` arrives with `type` absent. + it.each([ + ['no `type` (the spec default)', { lockedBy: 'admin@objectos.ai' }, 'Shared view'], + ['`type: collaborative`', { type: 'collaborative' as const, lockedBy: 'admin@objectos.ai' }, 'Shared view'], + ['`type: personal`', { type: 'personal' as const, lockedBy: 'admin@objectos.ai' }, 'Personal view'], + ])('shows the lock from `sharing.lockedBy` with %s', (_case, sharing, typeLabel) => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name', 'email'], + sharing, + }; + + renderWithProvider(); + expect(screen.getByTestId('sharing-locked-by')).toHaveTextContent('Locked by admin@objectos.ai'); + expect(screen.getByTestId('share-button')).toHaveAttribute('title', `${typeLabel} · Locked by admin@objectos.ai`); + }); + + it('shows no sharing indicator for a `sharing` block with neither `type` nor `lockedBy`', () => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name', 'email'], + sharing: {}, + }; + + renderWithProvider(); + expect(screen.queryByTestId('share-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('sharing-locked-by')).not.toBeInTheDocument(); + }); }); // ============================