diff --git a/.changeset/12859-activity-refused-copy.md b/.changeset/12859-activity-refused-copy.md new file mode 100644 index 0000000000..2a728d3f20 --- /dev/null +++ b/.changeset/12859-activity-refused-copy.md @@ -0,0 +1,6 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +A refused activity read now shows one neutral sentence, "You don't have permission to view activity.", on Home's activity card and in the header bell's Activity tab. Home used to say "…activity on this record", where there is no record, and the bell showed "An unexpected error occurred.". The new key `layout.activityFeed.accessDenied` is in all ten language packs. The record activity panel keeps its own sentence. Any other failure still shows the generic error. diff --git a/packages/app-shell/src/console/home/HomeRail.tsx b/packages/app-shell/src/console/home/HomeRail.tsx index dd11518a05..a3fd0fa4a5 100644 --- a/packages/app-shell/src/console/home/HomeRail.tsx +++ b/packages/app-shell/src/console/home/HomeRail.tsx @@ -569,11 +569,13 @@ export function HomeContinue({ * the reader's language, the API name only when none resolves. * * A read the server REFUSED anyway (a grant the client believed, or no loaded - * grant to ask) says so with the record activity panel's own sentence and - * lock (objectui#12843), not the generic failure: the feed marks the refusal - * with that panel's verdict (`isRefusedFeedRead`, objectui#11195), and this - * card reads the same `detail.activityAccessDenied` key. Every other failure - * keeps the generic copy. + * grant to ask) says so with a lock and a permission sentence (objectui#12843), + * not the generic failure: the feed marks the refusal with the record activity + * panel's verdict (`isRefusedFeedRead`, objectui#11195). The sentence is the + * neutral `layout.activityFeed.accessDenied`, the one the bell's Activity tab + * shows for the same refusal (objectui#12859); the record panel's + * `detail.activityAccessDenied` names "this record", and Home has none. Every + * other failure keeps the generic copy. * * Laid out as Home's section ④ (objectui#12604): the heading above a card with * 12 px padding; each row the actor (13 px / 500), the verb and target muted, @@ -596,8 +598,8 @@ export function HomeActivity({ activity, onViewAll, t }: { activity: ActivityFee {activity.status === 'error' && activity.refused ? ( ) : activity.status === 'error' ? ( diff --git a/packages/app-shell/src/console/home/__tests__/HomeActivity.refusedRead-12843.test.tsx b/packages/app-shell/src/console/home/__tests__/HomeActivity.refusedRead-12843.test.tsx index 92e5422198..2ad9c2e4fd 100644 --- a/packages/app-shell/src/console/home/__tests__/HomeActivity.refusedRead-12843.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomeActivity.refusedRead-12843.test.tsx @@ -8,18 +8,22 @@ /** * objectui#12843 — a `sys_activity` read the server REFUSES shows, on Home's - * activity card, the permission sentence the record activity panel shows for - * the same refusal (objectui#11195), not "An unexpected error occurred.". + * activity card, a permission sentence and lock, not "An unexpected error + * occurred.". * - * One verdict decides it on both surfaces: the shared feed classifies its - * rejection with plugin-detail's `isRefusedFeedRead`, the function the record - * panel reads, and the card renders the same `detail.activityAccessDenied` key. + * One verdict decides it on Home and on the record activity panel: the shared + * feed classifies its rejection with plugin-detail's `isRefusedFeedRead`, the + * function the record panel reads (objectui#11195). The SENTENCE differs + * (objectui#12859): the record panel's `detail.activityAccessDenied` says + * "…on this record", and Home has no record, so the card reads the neutral + * `layout.activityFeed.accessDenied`, the one the bell's Activity tab reads. * The pins run the real hook (`useHumanActivityFeed`, Home's cut of the shared * feed) into the real card under the real en catalogue; only the adapter is a * fake, and it answers each case's way: * - * - a 403 `PERMISSION_DENIED` envelope ⇒ the record panel's sentence, read - * back from the record panel itself rendered beside it; + * - a 403 `PERMISSION_DENIED` envelope ⇒ the neutral sentence, and not the + * record panel's, read back from the record panel rendered beside it; + * - CONTROL: the record panel beside it still says "…on this record"; * - CONTROL: a 500 ⇒ the generic failure, unchanged; * - CONTROL: an answered read ⇒ the rows. */ @@ -61,6 +65,7 @@ vi.mock('../../../providers/AdapterProvider', () => ({ useAdapter: () => fakeAda const { useHumanActivityFeed, __resetSharedUserFeeds } = await import('../../../hooks/sharedUserFeeds'); const GENERIC_ERROR = 'An unexpected error occurred.'; +const NEUTRAL_DENIED = "You don't have permission to view activity."; /** Home's card, fed by Home's cut of the shared feed, with the console's translator. */ function HomeCard() { @@ -97,8 +102,8 @@ afterEach(() => { vi.useRealTimers(); }); -describe("Home's activity card shows the record panel's permission copy for a refused read (objectui#12843)", () => { - it('a 403 on sys_activity: the card reads the same sentence the record activity panel shows', async () => { +describe("Home's activity card shows a permission sentence for a refused read (objectui#12843, objectui#12859)", () => { + it('a 403 on sys_activity: the card reads the neutral sentence, not the record panel\'s "…on this record"', async () => { readError = Object.assign(new Error("operation 'find' on object 'sys_activity' is not permitted"), { httpStatus: 403, code: 'PERMISSION_DENIED', @@ -108,9 +113,12 @@ describe("Home's activity card shows the record panel's permission copy for a re const recordSentence = screen .getByTestId('record-panel') .querySelector('[data-denied-source="activity"]')?.textContent; - expect(recordSentence).toBeTruthy(); + // CONTROL: the record panel keeps its record-specific sentence. + expect(recordSentence).toBe("You don't have permission to view activity on this record."); const notice = screen.getByTestId('home-activity-access-denied'); - expect(notice).toHaveTextContent(recordSentence as string); + expect(notice).toHaveTextContent(NEUTRAL_DENIED); + expect(notice).not.toHaveTextContent('this record'); + expect(notice.querySelector('svg')).not.toBeNull(); expect(screen.getByTestId('home-activity-unanswered')).not.toHaveTextContent(GENERIC_ERROR); // Not an empty-feed claim either. expect(screen.queryByText('No recent activity')).not.toBeInTheDocument(); @@ -133,11 +141,11 @@ describe("Home's activity card shows the record panel's permission copy for a re }); }); -describe('the card reads the record panel key, not a sentence of its own (objectui#12843)', () => { +describe('the card reads the neutral activity key, not the record panel key (objectui#12859)', () => { // A key-echo translator: what the card renders IS the key it asked for. const echo = (key: string) => key; - it('a refused error reading renders `detail.activityAccessDenied`', () => { + it('a refused error reading renders `layout.activityFeed.accessDenied`', () => { render( , ); - expect(screen.getByTestId('home-activity-access-denied')).toHaveTextContent('detail.activityAccessDenied'); + expect(screen.getByTestId('home-activity-access-denied')).toHaveTextContent('layout.activityFeed.accessDenied'); }); it('an error reading without the refusal renders `errors.unknown`', () => { diff --git a/packages/app-shell/src/layout/InboxPopover.tsx b/packages/app-shell/src/layout/InboxPopover.tsx index dfc9b74e06..a14325a16b 100644 --- a/packages/app-shell/src/layout/InboxPopover.tsx +++ b/packages/app-shell/src/layout/InboxPopover.tsx @@ -33,7 +33,7 @@ import { TabsTrigger, TabsContent, } from '@object-ui/components'; -import { Bell, CheckSquare, Activity as ActivityIcon, ChevronRight, CircleAlert } from 'lucide-react'; +import { Bell, CheckSquare, Activity as ActivityIcon, ChevronRight, CircleAlert, Lock } from 'lucide-react'; import { useObjectTranslation, useDisplayLocale } from '@object-ui/i18n'; import type { ActivityFeedReading } from '../hooks/sharedUserFeeds.js'; import { useNavigationContext } from '../context/NavigationContext.js'; @@ -122,8 +122,9 @@ export interface InboxPopoverProps { * no drill into a list page that answers this caller `403`; * - `status: 'ready'` — the rows ARE the answer; only here does an empty * list say "No recent activity"; - * - `status: 'error'` — the read failed (a refusal included): the tab says - * so, beside whatever rows it last had; + * - `status: 'error'` — the read failed: the tab says so, beside whatever + * rows it last had; a refusal (`refused`) with the permission sentence + * Home shows, any other failure with the generic one (objectui#12859); * - `idle` / `loading` — not asked yet, or in flight. */ activity: ActivityFeedReading; @@ -670,7 +671,17 @@ export function InboxPopover({ className="flex items-center justify-center gap-2 px-3 py-3 text-sm text-muted-foreground" data-testid="inbox-activity-unanswered" > - {activity.status === 'error' ? ( + {activity.status === 'error' && activity.refused ? ( + // A refused read (the feed's `isRefusedFeedRead` verdict) + // says so with Home's lock and sentence (objectui#12859); + // every other failure keeps the generic copy. + + + ) : activity.status === 'error' ? ( <> {t('errors.unknown', { defaultValue: 'An unexpected error occurred.' })} diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.inboxVariant.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.inboxVariant.test.tsx index 5384db8580..4924a703fe 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.inboxVariant.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.inboxVariant.test.tsx @@ -849,17 +849,30 @@ describe('AppHeader — the Activity tab asks only a caller who may read sys_act expect(screen.queryByText('No recent activity')).not.toBeInTheDocument(); }); - it('a 403 from the server (a stale grant) reads as failed, never as "No recent activity"', async () => { + it('a 403 from the server (a stale grant) reads as refused, never as "No recent activity" (objectui#12859)', async () => { activityError = Object.assign(new Error("operation 'find' on object 'sys_activity' is not permitted"), { httpStatus: 403, code: 'PERMISSION_DENIED', }); renderBell(ACTIVITY_READER); + await waitFor(() => expect(activityReads()).toHaveLength(1)); + const unanswered = await screen.findByTestId('inbox-activity-unanswered'); + expect(unanswered).toHaveTextContent("You don't have permission to view activity."); + expect(unanswered).not.toHaveTextContent('An unexpected error occurred.'); + expect(screen.getByTestId('inbox-activity-access-denied')).toBeInTheDocument(); + expect(screen.queryByText('No recent activity')).not.toBeInTheDocument(); + }); + + it('CONTROL: a 500 reads as failed with the generic copy, never as refused (objectui#12859)', async () => { + activityError = Object.assign(new Error('Internal Server Error'), { httpStatus: 500 }); + renderBell(ACTIVITY_READER); + await waitFor(() => expect(activityReads()).toHaveLength(1)); expect(await screen.findByTestId('inbox-activity-unanswered')).toHaveTextContent( 'An unexpected error occurred.', ); + expect(screen.queryByTestId('inbox-activity-access-denied')).not.toBeInTheDocument(); expect(screen.queryByText('No recent activity')).not.toBeInTheDocument(); }); diff --git a/packages/app-shell/src/layout/__tests__/InboxPopover.refusedActivity-12859.test.tsx b/packages/app-shell/src/layout/__tests__/InboxPopover.refusedActivity-12859.test.tsx new file mode 100644 index 0000000000..addf859b44 --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/InboxPopover.refusedActivity-12859.test.tsx @@ -0,0 +1,140 @@ +/** + * 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. + */ + +/** + * objectui#12859 — a refused `sys_activity` read shows ONE neutral sentence on + * both surfaces off a record page: Home's activity card and the header bell's + * Activity tab. The bell used to draw "An unexpected error occurred." for it, + * and Home borrowed the record panel's "…on this record", where there is no + * record. + * + * The feed already marks the refusal (`refused`, set by `isRefusedFeedRead`, + * objectui#12843); these pins hand the real `InboxPopover` and the real + * `HomeActivity` a reading under the real catalogue, in two languages, and + * read the sentence back. The real-hook path into the bell (a 403 from the + * adapter through `AppHeader`) is pinned in `AppHeader.inboxVariant.test.tsx`. + * + * - refused ⇒ the bell and Home show the same sentence, with a lock, and it + * is not the record panel's sentence rendered beside them; + * - CONTROL: an error that is not a refusal ⇒ the generic failure on both. + * + * Direction, written before the run: with the bell's branch reading + * `activity.status === 'error'` alone again, the refused cells go RED (the + * bell shows the generic failure) and the CONTROL cells stay GREEN. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { createI18n, I18nProvider, useObjectTranslation } from '@object-ui/i18n'; +import { RecordActivityTimeline } from '@object-ui/plugin-detail'; +import { withRoster } from '../../hooks/__tests__/activityRoster'; +import { HomeActivity } from '../../console/home/HomeRail'; +import { InboxPopover } from '../InboxPopover'; +import type { ActivityFeedReading } from '../../hooks/sharedUserFeeds'; + +type Lang = 'en' | 'zh-CN'; + +const NEUTRAL: Record = { + en: "You don't have permission to view activity.", + 'zh-CN': '您没有查看动态的权限。', +}; +const GENERIC: Record = { + en: 'An unexpected error occurred.', + 'zh-CN': '发生未知错误。', +}; + +const REFUSED: ActivityFeedReading = { value: [], status: 'error', readable: true, refused: true }; +const FAILED: ActivityFeedReading = { value: [], status: 'error', readable: true }; + +function HomeCard({ activity }: { activity: ActivityFeedReading }) { + const { t } = useObjectTranslation(); + return {}} t={t} />; +} + +function renderSurfaces(lang: Lang, activity: ActivityFeedReading) { + const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); + expect(i18n.language).toBe(lang); + const Roster = withRoster(); + render( + + + +
+ {}} + onMarkRead={() => {}} + /> +
+ +
+ +
+
+
+
, + ); +} + +/** Open the bell and select the Activity tab, its last trigger. */ +function openActivityTab() { + fireEvent.click(screen.getByTestId('bell').querySelector('button') as HTMLButtonElement); + const tabs = screen.getAllByRole('tab'); + // Radix tabs activate on a primary-button mouse down. + act(() => { + fireEvent.mouseDown(tabs[tabs.length - 1], { button: 0, ctrlKey: false }); + }); +} + +afterEach(() => { + cleanup(); +}); + +describe.each(['en', 'zh-CN'])('a refused activity read off a record page, in %s (objectui#12859)', (lang) => { + it("the bell's Activity tab shows the neutral sentence with a lock, not the generic failure", () => { + renderSurfaces(lang, REFUSED); + openActivityTab(); + + const notice = screen.getByTestId('inbox-activity-access-denied'); + expect(notice).toHaveTextContent(NEUTRAL[lang]); + expect(notice.querySelector('svg')).not.toBeNull(); + expect(screen.getByTestId('inbox-activity-unanswered')).not.toHaveTextContent(GENERIC[lang]); + }); + + it('Home shows the same sentence as the bell, and neither shows the record panel sentence', () => { + renderSurfaces(lang, REFUSED); + openActivityTab(); + + const bell = screen.getByTestId('inbox-activity-access-denied').textContent; + const home = screen.getByTestId('home-activity-access-denied').textContent; + expect(home).toBe(bell); + expect(home).toBe(NEUTRAL[lang]); + + // CONTROL: the record panel keeps its own, record-specific sentence. + const record = screen + .getByTestId('record-panel') + .querySelector('[data-denied-source="activity"]')?.textContent; + expect(record).toBeTruthy(); + expect(record).not.toBe(home); + }); + + it('CONTROL: an error that is not a refusal keeps the generic failure on both surfaces', () => { + renderSurfaces(lang, FAILED); + openActivityTab(); + + expect(screen.getByTestId('inbox-activity-unanswered')).toHaveTextContent(GENERIC[lang]); + expect(screen.queryByTestId('inbox-activity-access-denied')).not.toBeInTheDocument(); + expect(screen.getByTestId('home-activity-unanswered')).toHaveTextContent(GENERIC[lang]); + expect(screen.queryByTestId('home-activity-access-denied')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 7426164984..0c6af82cde 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3016,6 +3016,7 @@ const ar = { title: "النشاط الأخير", filter: "تصفية", empty: "لا نشاط أخير", + accessDenied: "ليس لديك صلاحية عرض النشاط.", ariaLabel: "تغذية النشاط", typeCreate: "إنشاء", typeUpdate: "تحديث", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index b7c4e817d6..320522cf64 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2796,6 +2796,7 @@ const de = { title: "Letzte Aktivitäten", filter: "Filtern", empty: "Keine letzten Aktivitäten", + accessDenied: "Sie haben keine Berechtigung, Aktivitäten anzuzeigen.", ariaLabel: "Aktivitätsfeed", typeCreate: "Erstellen", typeUpdate: "Aktualisieren", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 11b9cd750b..6a1d3883e9 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3339,6 +3339,7 @@ const en = { title: 'Recent Activity', filter: 'Filter', empty: 'No recent activity', + accessDenied: "You don't have permission to view activity.", viewAll: 'View all activity', ariaLabel: 'Activity feed', typeCreate: 'Create', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 8d96576b26..95d3988728 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2850,6 +2850,7 @@ const es = { title: "Actividad reciente", filter: "Filtrar", empty: "Sin actividad reciente", + accessDenied: "No tiene permiso para ver la actividad.", ariaLabel: "Feed de actividad", typeCreate: "Crear", typeUpdate: "Actualizar", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 85fa2bfa61..cf6ced52af 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2847,6 +2847,7 @@ const fr = { title: "Activité récente", filter: "Filtrer", empty: "Aucune activité récente", + accessDenied: "Vous n'avez pas l'autorisation de consulter l'activité.", ariaLabel: "Fil d'activité", typeCreate: "Créer", typeUpdate: "Mettre à jour", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index fe7460dc1a..4c83cc9819 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2798,6 +2798,7 @@ const ja = { title: "最近の活動", filter: "フィルター", empty: "最近の活動はありません", + accessDenied: "アクティビティを表示する権限がありません。", ariaLabel: "アクティビティフィード", typeCreate: "作成", typeUpdate: "更新", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 404841acc7..5652295552 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2795,6 +2795,7 @@ const ko = { title: "최근 활동", filter: "필터", empty: "최근 활동 없음", + accessDenied: "활동을 볼 권한이 없습니다.", ariaLabel: "활동 피드", typeCreate: "생성", typeUpdate: "업데이트", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 4ac508a746..4c39fd41d2 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2845,6 +2845,7 @@ const pt = { title: "Atividade recente", filter: "Filtrar", empty: "Sem atividade recente", + accessDenied: "Você não tem permissão para ver a atividade.", ariaLabel: "Feed de atividade", typeCreate: "Criar", typeUpdate: "Atualizar", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 9ec1c2aa69..ca5b870baf 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2917,6 +2917,7 @@ const ru = { title: "Последние действия", filter: "Фильтр", empty: "Нет действий", + accessDenied: "У вас нет разрешения на просмотр активности.", ariaLabel: "Лента активности", typeCreate: "Создание", typeUpdate: "Обновление", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 1c04820095..fe4e830ca7 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2875,6 +2875,7 @@ const zh = { title: '最近动态', filter: '筛选', empty: '暂无最近动态', + accessDenied: '您没有查看动态的权限。', viewAll: '查看全部动态', ariaLabel: '动态消息', typeCreate: '创建',