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
20 changes: 20 additions & 0 deletions .changeset/25898-list-empty-state-access.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.record.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,6 @@
"ui/packages/types"
],
"registrySources": 922,
"registrySourcesSha256": "0e127be18a1104d857822dc5c71a6380e4f00b0db729cd8b8a230a11c120fe78"
"registrySourcesSha256": "7a4b546825cb499c56c72e11df824357786d9619b78c8835591fbe91a6ae6110"
}
}
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,10 @@ const ar = {
noMatches: "لا توجد سجلات مطابقة",
noMatchesMessage: "لا توجد سجلات تطابق عوامل التصفية أو البحث الحالية. حاول تعديلها أو مسحها.",
viewFilterNoMatchesMessage: "لا توجد سجلات تطابق عامل التصفية لطريقة العرض هذه.",
ownedNoneTitle: "لا يوجد شيء يخصك بعد",
ownedNoneMessage: "تعرض طريقة العرض هذه سجلاتك فقط، وليس لديك أي سجل بعد.",
noneVisibleTitle: "لا توجد سجلات يمكنك رؤيتها",
noneVisibleMessage: "لا توجد هنا سجلات لديك صلاحية الوصول إليها. إذا كنت تتوقع رؤية بعضها، فاطلب من المسؤول منحك الوصول.",
loadErrorTitle: "تعذّر تحميل السجلات",
loadErrorMessage: "حدث خطأ أثناء تحميل هذه البيانات. تحقق من اتصالك وحاول مرة أخرى.",
loadErrorForbiddenTitle: "ليس لديك صلاحية الوصول",
Expand Down
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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é",
Expand Down
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,10 @@ const ja = {
noMatches: "該当するレコードがありません",
noMatchesMessage: "現在のフィルターや検索条件に一致するレコードはありません。条件を調整するか解除してください。",
viewFilterNoMatchesMessage: "このビューのフィルターに一致するレコードはありません。",
ownedNoneTitle: "あなたのレコードはまだありません",
ownedNoneMessage: "このビューにはあなたのレコードだけが表示されますが、まだ1件もありません。",
noneVisibleTitle: "表示できるレコードがありません",
noneVisibleMessage: "ここにはあなたがアクセスできるレコードがありません。表示されるはずの場合は、管理者にアクセス権を依頼してください。",
loadErrorTitle: "レコードを読み込めませんでした",
loadErrorMessage: "データの読み込み中に問題が発生しました。接続を確認して再試行してください。",
loadErrorForbiddenTitle: "アクセス権がありません",
Expand Down
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,10 @@ const ko = {
noMatches: "일치하는 레코드가 없습니다",
noMatchesMessage: "현재 필터나 검색어와 일치하는 레코드가 없습니다. 조건을 조정하거나 지워 보세요.",
viewFilterNoMatchesMessage: "이 뷰의 필터와 일치하는 레코드가 없습니다.",
ownedNoneTitle: "아직 내 레코드가 없습니다",
ownedNoneMessage: "이 뷰에는 내 레코드만 표시되며, 아직 하나도 없습니다.",
noneVisibleTitle: "볼 수 있는 레코드가 없습니다",
noneVisibleMessage: "여기에는 접근 권한이 있는 레코드가 없습니다. 레코드가 보여야 한다면 관리자에게 접근 권한을 요청하세요.",
loadErrorTitle: "레코드를 불러오지 못했습니다",
loadErrorMessage: "이 데이터를 불러오는 중 문제가 발생했습니다. 연결을 확인하고 다시 시도하세요.",
loadErrorForbiddenTitle: "접근 권한이 없습니다",
Expand Down
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,10 @@ const ru = {
noMatches: "Подходящих записей нет",
noMatchesMessage: "Ни одна запись не соответствует текущим фильтрам или поиску. Измените или сбросьте их.",
viewFilterNoMatchesMessage: "Ни одна запись не соответствует фильтру этого представления.",
ownedNoneTitle: "У вас пока ничего нет",
ownedNoneMessage: "В этом представлении показаны только ваши записи, а у вас их пока нет.",
noneVisibleTitle: "Нет доступных вам записей",
noneVisibleMessage: "Здесь нет записей, к которым у вас есть доступ. Если вы ожидали их увидеть, запросите доступ у администратора.",
loadErrorTitle: "Не удалось загрузить записи",
loadErrorMessage: "При загрузке данных произошла ошибка. Проверьте соединение и попробуйте снова.",
loadErrorForbiddenTitle: "Нет доступа",
Expand Down
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,10 @@ const zh = {
noMatches: '没有匹配的记录',
noMatchesMessage: '没有符合当前筛选或搜索条件的记录,试试调整或清除它们。',
viewFilterNoMatchesMessage: '没有符合该视图筛选条件的记录。',
ownedNoneTitle: '你还没有负责的记录',
ownedNoneMessage: '该视图只列出属于你的记录,你目前还没有。',
noneVisibleTitle: '没有你可查看的记录',
noneVisibleMessage: '这里没有你有权查看的记录。如果你认为应该能看到,请联系管理员开通权限。',
loadErrorTitle: '无法加载记录',
loadErrorMessage: '加载数据时出错。请检查网络连接后重试。',
loadErrorForbiddenTitle: '无权访问',
Expand Down
41 changes: 39 additions & 2 deletions ui/packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,13 @@ const GRID_DEFAULT_TRANSLATIONS: Record<string, string> = {
'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
Expand Down Expand Up @@ -7485,13 +7492,43 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
&& 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 ? (
<DataEmptyState
data-testid="object-grid-empty-state"
className="flex-1 min-h-[200px]"
title={viewFilterEmptiedRows ? t('list.noMatches') : t('list.firstRunTitle')}
description={viewFilterEmptiedRows ? t('list.viewFilterNoMatchesMessage') : t('list.firstRunMessage')}
title={t(defaultEmptyCopy.title)}
description={t(defaultEmptyCopy.message)}
/>
) : drawsAuthoredEmptyState ? (
<DataEmptyState
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* 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.
*
* objectstack#25898 — an empty `object-grid` tells "no records you can see"
* and "nothing of yours yet" apart from "no records", the way `ListEmptyState`
* (plugin-list) does and in the same `list.*` words.
*
* - No filter and a caller KNOWN not to hold the create grant (the `listNew`
* row, asked once permissions have loaded): "No records you can see", never
* the first-run invitation to create.
* - A declared filter naming the signed-in user (resolved to the caller's id,
* or the `{current_user_id}` placeholder): "Nothing of yours yet", not "No
* records match this view's filter".
*
* SUITE DIRECTION, predicted before running: the read-only and "my" cases are
* RED against the base commit; the create-grant, no-provider and
* plain-filter controls are green in both worlds.
*/

import React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import { FilterScopeProvider } from '@object-ui/react';
import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions';
// Registers `object-grid`, the block under test.
import { ObjectGridRenderer } from '../index';

afterEach(cleanup);

const OBJECT = 'crm_case';
const USER = 'u-rep';

const READ_ONLY = { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false };
const FULL = { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true };

const envelope = (bits: Record<string, boolean>): MePermissionsResponse => ({
authenticated: true,
userId: USER,
tenantId: null,
roles: ['member'],
permissionSets: ['member'],
objects: { [OBJECT]: bits },
fields: {},
});

const grid = (extra: Record<string, unknown> = {}) => ({
type: 'object-grid',
objectName: OBJECT,
columns: ['name'],
data: { provider: 'value', items: [] },
...extra,
});

function mount(schema: Record<string, unknown>, bits?: Record<string, boolean>, currentUserId?: string) {
let tree: React.ReactNode = <ObjectGridRenderer schema={schema} />;
if (currentUserId) tree = <FilterScopeProvider currentUserId={currentUserId}>{tree}</FilterScopeProvider>;
if (bits) tree = <MePermissionsProvider initialPermissions={envelope(bits)}>{tree}</MePermissionsProvider>;
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');
});
});
Loading
Loading