From 202b8843ebffbf354c4d7e2d62fd627223fdd52c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 06:59:07 +0000 Subject: [PATCH 1/4] fix(plugin-list,plugin-grid): list empty state tells no-access and "mine" apart An empty list without a filter now reads "No records you can see" for a caller known not to hold the create grant (the listNew affordance row), and never draws the add-record action for that caller. A view whose declared filter names the signed-in user reads "Nothing of yours yet" instead of "No records match this view's filter". ListView, the standalone ObjectGallery and ObjectGrid's default empty state all decide the same way. Four list.* keys added to the ten locale packs. Claude-Session: https://claude.ai/code/session_01RYq63NT6L4eGmQNvE9e7D8 Co-authored-by: Claude --- ui/packages/i18n/src/locales/ar.ts | 4 + ui/packages/i18n/src/locales/de.ts | 4 + ui/packages/i18n/src/locales/en.ts | 4 + ui/packages/i18n/src/locales/es.ts | 4 + ui/packages/i18n/src/locales/fr.ts | 4 + ui/packages/i18n/src/locales/ja.ts | 4 + ui/packages/i18n/src/locales/ko.ts | 4 + ui/packages/i18n/src/locales/pt.ts | 4 + ui/packages/i18n/src/locales/ru.ts | 4 + ui/packages/i18n/src/locales/zh.ts | 4 + ui/packages/plugin-grid/src/ObjectGrid.tsx | 41 +++++++++- .../plugin-list/src/ListEmptyState.tsx | 77 ++++++++++++++++--- ui/packages/plugin-list/src/ListView.tsx | 20 ++++- ui/packages/plugin-list/src/ObjectGallery.tsx | 12 ++- 14 files changed, 176 insertions(+), 14 deletions(-) diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index ac057c1c960..175666b4fa3 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -839,6 +839,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 2dca8512ae5..291db8171f1 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -749,6 +749,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 ef2ecfa2f7c..24235c5bea3 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1032,6 +1032,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 464ece759f5..707d3a2edb4 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -775,6 +775,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 16c2d6bef70..7199e81d7d5 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -770,6 +770,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 ae922186d1a..a5e64b61133 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -749,6 +749,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 4d2e691ec58..789dbdec67d 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -749,6 +749,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 6947902e67b..2d2641410c7 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -770,6 +770,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 bd5316c6919..a94918eaaba 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -794,6 +794,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 e7253201c8d..c64d0ba3a68 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -804,6 +804,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 5f7ea5a8b0c..4cee7fd6e8e 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 @@ -7474,13 +7481,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 ? ( { + 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 ? (