diff --git a/.changeset/25898-list-empty-state-access.md b/.changeset/25898-list-empty-state-access.md new file mode 100644 index 00000000000..300161843df --- /dev/null +++ b/.changeset/25898-list-empty-state-access.md @@ -0,0 +1,20 @@ +--- +'@object-ui/plugin-list': patch +'@object-ui/plugin-grid': patch +'@object-ui/i18n': patch +--- + +fix(plugin-list,plugin-grid): an empty list tells "no records you can see" and "nothing of yours yet" apart from "no records" + +Clause-②: no +Metadata-protocol: none + +An empty list chose its copy from the user's query and the view's filter alone. A member who may read none of an object's records and may not create one was told "Nothing here yet / Create your first record to get started." beside no Create button, and every "my …" view (`owner` equals the current user) told someone who owns nothing "No records match this view's filter." + +- **No filter, no create grant.** When the permission answer has loaded and the caller does not hold the create grant (the `listNew` row of the affordance-to-grant map, the verdict the page's own New reads), the empty state reads "No records you can see" and asks the reader to contact an administrator. The add-record action is not drawn for that caller, even on a view that enables `addRecord`. +- **A filter naming the signed-in user.** When the view's declared filter compares a field to the caller's own id (or still carries the `{current_user_id}` placeholder), the empty state reads "Nothing of yours yet", with the add-record action only under the create grant. +- **Unchanged.** The first-run copy and action for a caller who may create, the user-query and view-filter copy, an authored `emptyState`, the 403 error panel, and every surface with no permission provider mounted. + +`ListView` (grid and gallery), the standalone `ObjectGallery` and `ObjectGrid`'s default empty state decide the same way. `@object-ui/i18n` gains `list.ownedNoneTitle`, `list.ownedNoneMessage`, `list.noneVisibleTitle` and `list.noneVisibleMessage` in all ten locale packs. + +What to do: nothing. A view that authors its own `emptyState` keeps it. diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 684a23dffc1..fdbe7220632 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "0e127be18a1104d857822dc5c71a6380e4f00b0db729cd8b8a230a11c120fe78" + "registrySourcesSha256": "7a4b546825cb499c56c72e11df824357786d9619b78c8835591fbe91a6ae6110" } } diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 476ee91451e..eac8180d458 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -843,6 +843,10 @@ const ar = { noMatches: "لا توجد سجلات مطابقة", noMatchesMessage: "لا توجد سجلات تطابق عوامل التصفية أو البحث الحالية. حاول تعديلها أو مسحها.", viewFilterNoMatchesMessage: "لا توجد سجلات تطابق عامل التصفية لطريقة العرض هذه.", + ownedNoneTitle: "لا يوجد شيء يخصك بعد", + ownedNoneMessage: "تعرض طريقة العرض هذه سجلاتك فقط، وليس لديك أي سجل بعد.", + noneVisibleTitle: "لا توجد سجلات يمكنك رؤيتها", + noneVisibleMessage: "لا توجد هنا سجلات لديك صلاحية الوصول إليها. إذا كنت تتوقع رؤية بعضها، فاطلب من المسؤول منحك الوصول.", loadErrorTitle: "تعذّر تحميل السجلات", loadErrorMessage: "حدث خطأ أثناء تحميل هذه البيانات. تحقق من اتصالك وحاول مرة أخرى.", loadErrorForbiddenTitle: "ليس لديك صلاحية الوصول", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index f3714b5a787..a8695048e2f 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -753,6 +753,10 @@ const de = { noMatches: "Keine passenden Datensätze", noMatchesMessage: "Keine Datensätze entsprechen Ihren aktuellen Filtern oder Ihrer Suche. Passen Sie sie an oder setzen Sie sie zurück.", viewFilterNoMatchesMessage: "Keine Datensätze entsprechen dem Filter dieser Ansicht.", + ownedNoneTitle: "Noch nichts von Ihnen", + ownedNoneMessage: "Diese Ansicht listet nur Ihre eigenen Datensätze auf, und Sie haben noch keine.", + noneVisibleTitle: "Keine sichtbaren Datensätze", + noneVisibleMessage: "Hier gibt es keine Datensätze, auf die Sie Zugriff haben. Wenn Sie welche erwarten, bitten Sie Ihren Administrator um Zugriff.", loadErrorTitle: "Datensätze konnten nicht geladen werden", loadErrorMessage: "Beim Laden dieser Daten ist etwas schiefgelaufen. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.", loadErrorForbiddenTitle: "Kein Zugriff", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 1fb9ef1160f..a33346faa3b 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1036,6 +1036,10 @@ const en = { noMatches: 'No matching records', noMatchesMessage: 'No records match your current filters or search. Try adjusting or clearing them.', viewFilterNoMatchesMessage: 'No records match this view’s filter.', + ownedNoneTitle: 'Nothing of yours yet', + ownedNoneMessage: 'This view lists only records that are yours, and you don’t have any yet.', + noneVisibleTitle: 'No records you can see', + noneVisibleMessage: 'There are no records here that you have access to. If you expected to see some, ask your administrator for access.', loadErrorTitle: 'Couldn\u2019t load records', loadErrorMessage: 'Something went wrong while loading this data. Check your connection and try again.', loadErrorForbiddenTitle: 'You don\u2019t have access', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 168ca7bb05c..e4447cd7f52 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -779,6 +779,10 @@ const es = { noMatches: "No hay registros coincidentes", noMatchesMessage: "Ningún registro coincide con sus filtros o su búsqueda actuales. Pruebe a ajustarlos o borrarlos.", viewFilterNoMatchesMessage: "Ningún registro coincide con el filtro de esta vista.", + ownedNoneTitle: "Aún no tiene nada propio", + ownedNoneMessage: "Esta vista solo muestra sus propios registros, y todavía no tiene ninguno.", + noneVisibleTitle: "No hay registros que pueda ver", + noneVisibleMessage: "Aquí no hay registros a los que tenga acceso. Si esperaba ver alguno, pida acceso a su administrador.", loadErrorTitle: "No se pudieron cargar los registros", loadErrorMessage: "Algo salió mal al cargar estos datos. Compruebe su conexión y vuelva a intentarlo.", loadErrorForbiddenTitle: "No tiene acceso", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 36a583e9e77..e676ea2c1b9 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -774,6 +774,10 @@ const fr = { noMatches: "Aucun enregistrement correspondant", noMatchesMessage: "Aucun enregistrement ne correspond à vos filtres ou à votre recherche. Essayez de les ajuster ou de les effacer.", viewFilterNoMatchesMessage: "Aucun enregistrement ne correspond au filtre de cette vue.", + ownedNoneTitle: "Rien à vous pour l'instant", + ownedNoneMessage: "Cette vue ne liste que vos propres enregistrements, et vous n'en avez encore aucun.", + noneVisibleTitle: "Aucun enregistrement visible", + noneVisibleMessage: "Il n'y a ici aucun enregistrement auquel vous avez accès. Si vous pensiez en voir, demandez l'accès à votre administrateur.", loadErrorTitle: "Impossible de charger les enregistrements", loadErrorMessage: "Une erreur s'est produite lors du chargement de ces données. Vérifiez votre connexion et réessayez.", loadErrorForbiddenTitle: "Accès refusé", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 267f2b165cc..fdfd3525642 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -753,6 +753,10 @@ const ja = { noMatches: "該当するレコードがありません", noMatchesMessage: "現在のフィルターや検索条件に一致するレコードはありません。条件を調整するか解除してください。", viewFilterNoMatchesMessage: "このビューのフィルターに一致するレコードはありません。", + ownedNoneTitle: "あなたのレコードはまだありません", + ownedNoneMessage: "このビューにはあなたのレコードだけが表示されますが、まだ1件もありません。", + noneVisibleTitle: "表示できるレコードがありません", + noneVisibleMessage: "ここにはあなたがアクセスできるレコードがありません。表示されるはずの場合は、管理者にアクセス権を依頼してください。", loadErrorTitle: "レコードを読み込めませんでした", loadErrorMessage: "データの読み込み中に問題が発生しました。接続を確認して再試行してください。", loadErrorForbiddenTitle: "アクセス権がありません", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 234b8d26b37..0f23549e44e 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -753,6 +753,10 @@ const ko = { noMatches: "일치하는 레코드가 없습니다", noMatchesMessage: "현재 필터나 검색어와 일치하는 레코드가 없습니다. 조건을 조정하거나 지워 보세요.", viewFilterNoMatchesMessage: "이 뷰의 필터와 일치하는 레코드가 없습니다.", + ownedNoneTitle: "아직 내 레코드가 없습니다", + ownedNoneMessage: "이 뷰에는 내 레코드만 표시되며, 아직 하나도 없습니다.", + noneVisibleTitle: "볼 수 있는 레코드가 없습니다", + noneVisibleMessage: "여기에는 접근 권한이 있는 레코드가 없습니다. 레코드가 보여야 한다면 관리자에게 접근 권한을 요청하세요.", loadErrorTitle: "레코드를 불러오지 못했습니다", loadErrorMessage: "이 데이터를 불러오는 중 문제가 발생했습니다. 연결을 확인하고 다시 시도하세요.", loadErrorForbiddenTitle: "접근 권한이 없습니다", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index e538e3b0cf9..da93c21309a 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -774,6 +774,10 @@ const pt = { noMatches: "Nenhum registro correspondente", noMatchesMessage: "Nenhum registro corresponde aos seus filtros ou à sua busca atuais. Tente ajustá-los ou limpá-los.", viewFilterNoMatchesMessage: "Nenhum registro corresponde ao filtro desta visualização.", + ownedNoneTitle: "Ainda não há nada seu", + ownedNoneMessage: "Esta visualização lista apenas os seus registros, e você ainda não tem nenhum.", + noneVisibleTitle: "Nenhum registro que você possa ver", + noneVisibleMessage: "Não há registros aqui aos quais você tenha acesso. Se esperava ver algum, peça acesso ao seu administrador.", loadErrorTitle: "Não foi possível carregar os registros", loadErrorMessage: "Algo deu errado ao carregar estes dados. Verifique sua conexão e tente novamente.", loadErrorForbiddenTitle: "Você não tem acesso", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index fcb1150bec7..3e26915e619 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -798,6 +798,10 @@ const ru = { noMatches: "Подходящих записей нет", noMatchesMessage: "Ни одна запись не соответствует текущим фильтрам или поиску. Измените или сбросьте их.", viewFilterNoMatchesMessage: "Ни одна запись не соответствует фильтру этого представления.", + ownedNoneTitle: "У вас пока ничего нет", + ownedNoneMessage: "В этом представлении показаны только ваши записи, а у вас их пока нет.", + noneVisibleTitle: "Нет доступных вам записей", + noneVisibleMessage: "Здесь нет записей, к которым у вас есть доступ. Если вы ожидали их увидеть, запросите доступ у администратора.", loadErrorTitle: "Не удалось загрузить записи", loadErrorMessage: "При загрузке данных произошла ошибка. Проверьте соединение и попробуйте снова.", loadErrorForbiddenTitle: "Нет доступа", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 8d78c82a634..a42676bb155 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -808,6 +808,10 @@ const zh = { noMatches: '没有匹配的记录', noMatchesMessage: '没有符合当前筛选或搜索条件的记录,试试调整或清除它们。', viewFilterNoMatchesMessage: '没有符合该视图筛选条件的记录。', + ownedNoneTitle: '你还没有负责的记录', + ownedNoneMessage: '该视图只列出属于你的记录,你目前还没有。', + noneVisibleTitle: '没有你可查看的记录', + noneVisibleMessage: '这里没有你有权查看的记录。如果你认为应该能看到,请联系管理员开通权限。', loadErrorTitle: '无法加载记录', loadErrorMessage: '加载数据时出错。请检查网络连接后重试。', loadErrorForbiddenTitle: '无权访问', diff --git a/ui/packages/plugin-grid/src/ObjectGrid.tsx b/ui/packages/plugin-grid/src/ObjectGrid.tsx index 189e73d15b7..aafaa39eb4a 100644 --- a/ui/packages/plugin-grid/src/ObjectGrid.tsx +++ b/ui/packages/plugin-grid/src/ObjectGrid.tsx @@ -500,6 +500,13 @@ const GRID_DEFAULT_TRANSLATIONS: Record = { 'list.firstRunMessage': 'Create your first record to get started.', 'list.noMatches': 'No matching records', 'list.viewFilterNoMatchesMessage': 'No records match this view’s filter.', + // objectstack#25898 — a grid filtered to the signed-in user, and an + // unfiltered grid for a caller who may not create (`ListEmptyState` says + // both in the same words). + 'list.ownedNoneTitle': 'Nothing of yours yet', + 'list.ownedNoneMessage': 'This view lists only records that are yours, and you don’t have any yet.', + 'list.noneVisibleTitle': 'No records you can see', + 'list.noneVisibleMessage': 'There are no records here that you have access to. If you expected to see some, ask your administrator for access.', // Heading of the record-detail overlay this grid opens on row click // (objectui#3426). Borrowed from the `detail.*` namespace rather than minted // as `grid.recordDetail`: `NavigationOverlay` already resolves @@ -7485,13 +7492,43 @@ export const ObjectGrid: React.FC = ({ && holdsNoRows && !userQueryEmptiedRows; const viewFilterEmptiedRows = schemaFilter !== undefined; + // objectstack#25898 — the two cases `ListEmptyState` tells apart, the same + // way: a declared filter naming the signed-in user (the "my …" view) emptied + // the grid because the caller has none yet; and with no filter, a caller + // KNOWN not to hold the create grant (the `listNew` row, asked once the + // permission answer has loaded — an unloaded provider answers every grant + // closed) is told there is nothing here they can see, never invited to + // create the first record. + // + // The filter is read as `ListEmptyState`'s `filterNamesCurrentUser` reads it, + // shape-agnostic: some string comparand is the caller's id (the host usually + // resolved the placeholder already) or the `{current_user_id}` placeholder + // itself. Neither package depends on the other, so the walk is restated here. + const namesCurrentUser = (node: unknown): boolean => { + if (typeof node === 'string') { + return /^\$?\{current_user_id\}$/.test(node) + || (!!filterScope.currentUserId && node === filterScope.currentUserId); + } + if (Array.isArray(node)) return node.some(namesCurrentUser); + if (node && typeof node === 'object') return Object.values(node).some(namesCurrentUser); + return false; + }; + const ownFilterEmptiedRows = viewFilterEmptiedRows && namesCurrentUser(schemaFilterSource); + const createKnownDenied = perms.isLoaded && !resolveAffordance('listNew', affordanceSource).allowed; + const defaultEmptyCopy = ownFilterEmptiedRows + ? { title: 'list.ownedNoneTitle', message: 'list.ownedNoneMessage' } + : viewFilterEmptiedRows + ? { title: 'list.noMatches', message: 'list.viewFilterNoMatchesMessage' } + : createKnownDenied + ? { title: 'list.noneVisibleTitle', message: 'list.noneVisibleMessage' } + : { title: 'list.firstRunTitle', message: 'list.firstRunMessage' }; const AuthoredEmptyIcon = drawsAuthoredEmptyState ? resolveIcon(authoredEmptyState?.icon) : null; const renderedGridContent = drawsDefaultEmptyState ? ( ) : drawsAuthoredEmptyState ? ( ): MePermissionsResponse => ({ + authenticated: true, + userId: USER, + tenantId: null, + roles: ['member'], + permissionSets: ['member'], + objects: { [OBJECT]: bits }, + fields: {}, +}); + +const grid = (extra: Record = {}) => ({ + type: 'object-grid', + objectName: OBJECT, + columns: ['name'], + data: { provider: 'value', items: [] }, + ...extra, +}); + +function mount(schema: Record, bits?: Record, currentUserId?: string) { + let tree: React.ReactNode = ; + if (currentUserId) tree = {tree}; + if (bits) tree = {tree}; + return render(<>{tree}); +} + +const emptyState = () => screen.findByTestId('object-grid-empty-state', {}, { timeout: 5000 }); + +describe('an empty object-grid says what the caller may see and do (objectstack#25898)', () => { + it('no filter, no create grant: "No records you can see", never the first-run invitation', async () => { + mount(grid(), READ_ONLY); + const state = await emptyState(); + expect(state.querySelector('h3')).toHaveTextContent('No records you can see'); + expect(state).toHaveTextContent('ask your administrator'); + expect(state).not.toHaveTextContent('Create your first record'); + }); + + it('CONTROL: no filter, the create grant: the first-run copy as before', async () => { + mount(grid(), FULL); + const state = await emptyState(); + expect(state.querySelector('h3')).toHaveTextContent('Nothing here yet'); + }); + + it('CONTROL: no permission provider: the first-run copy as before (fail-open)', async () => { + mount(grid()); + const state = await emptyState(); + expect(state.querySelector('h3')).toHaveTextContent('Nothing here yet'); + }); + + it('a filter resolved to the caller\'s id: "Nothing of yours yet"', async () => { + mount(grid({ filter: [['owner', '=', '{current_user_id}']] }), FULL, USER); + const state = await emptyState(); + expect(state.querySelector('h3')).toHaveTextContent('Nothing of yours yet'); + expect(state).not.toHaveTextContent('No records match this view'); + }); + + it('CONTROL: a filter about something else keeps the view-filter copy', async () => { + mount(grid({ filter: [['status', '=', 'open']] }), FULL, USER); + const state = await emptyState(); + expect(state.querySelector('h3')).toHaveTextContent('No matching records'); + }); +}); diff --git a/ui/packages/plugin-list/src/ListEmptyState.tsx b/ui/packages/plugin-list/src/ListEmptyState.tsx index 8c995b37a3e..128ee01d34d 100644 --- a/ui/packages/plugin-list/src/ListEmptyState.tsx +++ b/ui/packages/plugin-list/src/ListEmptyState.tsx @@ -44,6 +44,34 @@ export function hasDeclaredFilter(filter: unknown): boolean { return false; } +/** A whole-string session placeholder for the signed-in user, unresolved. */ +const CURRENT_USER_TOKEN_RE = /^\$?\{current_user_id\}$/; + +/** + * Whether a DECLARED filter names the signed-in user (objectstack#25898): some + * comparand in it is the caller's own id, or the `{current_user_id}` + * placeholder still unresolved. That is the "my …" view — `owner` equals the + * current user — whose empty list means "you have none yet", not "no record + * matches this view's filter". + * + * Shape-agnostic on purpose: a condition triple, a `{ field, operator, value }` + * condition and a MongoDB-style object all carry the comparand as a string + * leaf, and the filter a host hands a list is usually already resolved, so + * the id itself is what is left to find. Only the copy reads this; it narrows + * no query and grants nothing. + */ +export function filterNamesCurrentUser(filter: unknown, currentUserId: string | null | undefined): boolean { + const walk = (node: unknown): boolean => { + if (typeof node === 'string') { + return CURRENT_USER_TOKEN_RE.test(node) || (!!currentUserId && node === currentUserId); + } + if (Array.isArray(node)) return node.some(walk); + if (node && typeof node === 'object') return Object.values(node).some(walk); + return false; + }; + return walk(filter); +} + /** The authored `emptyState` (the spec's `EmptyStateSchema`). */ type AuthoredEmptyState = NonNullable; @@ -73,6 +101,21 @@ export interface ListEmptyStateProps { hasUserQuery: boolean; /** The declared filter narrows the query (see {@link hasDeclaredFilter}). */ hasBaseFilter: boolean; + /** + * The declared filter names the signed-in user (see + * {@link filterNamesCurrentUser}): a "my …" view, emptied because the caller + * has none yet (objectstack#25898). + */ + baseFilterNamesCurrentUser?: boolean; + /** + * Whether the caller may create a record here — the `listNew` row of the + * affordance-to-grant map (objectstack#25898). `false` is a KNOWN denial: the + * first-run invitation ("create your first record") is not shown, an empty + * list without a filter reads as "no records you can see", and no add-record + * action is drawn even when `onAddRecord` is supplied. Absent means not known + * (no permission answer yet, or no provider): today's copy. + */ + mayCreate?: boolean; /** Draws the add-record action when supplied; omitted, no action is drawn. */ onAddRecord?: () => void; /** The caller's `list.*` translation function. */ @@ -88,6 +131,8 @@ export function ListEmptyState({ link, hasUserQuery, hasBaseFilter, + baseFilterNamesCurrentUser = false, + mayCreate, onAddRecord, t, displayLocale, @@ -120,15 +165,29 @@ export function ListEmptyState({ // state silently drew the default copy in every locale. A string still // passes through unchanged, and a map with no usable entry still falls to // the default. - const title = resolveInlineI18nLabel(authoredTitle, displayLocale) - ?? (hasActiveQuery ? t('list.noMatches') : t('list.firstRunTitle')); - const description = resolveInlineI18nLabel(authoredMessage, displayLocale) - ?? (hasUserQuery - ? t('list.noMatchesMessage') - : hasBaseFilter - ? t('list.viewFilterNoMatchesMessage') - : t('list.firstRunMessage')); - const addRecordAction = onAddRecord ? ( + // + // objectstack#25898 — two more cases, read from what the list knows: + // - the declared filter names the signed-in user (a "my …" view): the list is + // empty because the caller has none yet, so it says that, and not that a + // filter matched nothing; + // - no filter at all and the caller may NOT create: the first-run invitation + // would tell a member who may read none of the object's records that the + // object is empty and invite them to create what they cannot. It reads as + // "no records you can see" instead, and names who to ask. + const defaultCopy = hasUserQuery + ? { title: 'list.noMatches', message: 'list.noMatchesMessage' } + : hasBaseFilter && baseFilterNamesCurrentUser + ? { title: 'list.ownedNoneTitle', message: 'list.ownedNoneMessage' } + : hasActiveQuery + ? { title: 'list.noMatches', message: 'list.viewFilterNoMatchesMessage' } + : mayCreate === false + ? { title: 'list.noneVisibleTitle', message: 'list.noneVisibleMessage' } + : { title: 'list.firstRunTitle', message: 'list.firstRunMessage' }; + const title = resolveInlineI18nLabel(authoredTitle, displayLocale) ?? t(defaultCopy.title); + const description = resolveInlineI18nLabel(authoredMessage, displayLocale) ?? t(defaultCopy.message); + // Create is offered only with the create grant (objectstack#25898): a known + // denial withholds the action whatever the caller handed over. + const addRecordAction = onAddRecord && mayCreate !== false ? (