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
6 changes: 6 additions & 0 deletions .changeset/12859-activity-refused-copy.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 9 additions & 7 deletions packages/app-shell/src/console/home/HomeRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -596,8 +598,8 @@ export function HomeActivity({ activity, onViewAll, t }: { activity: ActivityFee
{activity.status === 'error' && activity.refused ? (
<span className="flex items-center gap-2" data-testid="home-activity-access-denied">
<Lock className="h-4 w-4 shrink-0" aria-hidden="true" />
{t('detail.activityAccessDenied', {
defaultValue: "You don't have permission to view activity on this record.",
{t('layout.activityFeed.accessDenied', {
defaultValue: "You don't have permission to view activity.",
})}
</span>
) : activity.status === 'error' ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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',
Expand All @@ -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();
Expand All @@ -133,19 +141,19 @@ 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(
<HomeActivity
activity={{ value: [], status: 'error', readable: true, refused: true }}
onViewAll={() => {}}
t={echo}
/>,
);
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`', () => {
Expand Down
19 changes: 15 additions & 4 deletions packages/app-shell/src/layout/InboxPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
<span className="flex items-center gap-2" data-testid="inbox-activity-access-denied">
<Lock className="h-4 w-4 shrink-0" aria-hidden="true" />
{t('layout.activityFeed.accessDenied', {
defaultValue: "You don't have permission to view activity.",
})}
</span>
) : activity.status === 'error' ? (
<>
<CircleAlert className="h-4 w-4 text-amber-500" />
{t('errors.unknown', { defaultValue: 'An unexpected error occurred.' })}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Lang, string> = {
en: "You don't have permission to view activity.",
'zh-CN': '您没有查看动态的权限。',
};
const GENERIC: Record<Lang, string> = {
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 <HomeActivity activity={activity} onViewAll={() => {}} t={t} />;
}

function renderSurfaces(lang: Lang, activity: ActivityFeedReading) {
const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
expect(i18n.language).toBe(lang);
const Roster = withRoster();
render(
<MemoryRouter>
<I18nProvider instance={i18n} persistLanguage={false}>
<Roster>
<div data-testid="bell">
<InboxPopover
notifications={[]}
unreadCount={0}
pendingApprovalsCount={0}
activity={activity}
onMarkAllRead={() => {}}
onMarkRead={() => {}}
/>
</div>
<HomeCard activity={activity} />
<div data-testid="record-panel">
<RecordActivityTimeline items={[]} activityDenied />
</div>
</Roster>
</I18nProvider>
</MemoryRouter>,
);
}

/** 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<Lang>(['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();
});
});
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3016,6 +3016,7 @@ const ar = {
title: "النشاط الأخير",
filter: "تصفية",
empty: "لا نشاط أخير",
accessDenied: "ليس لديك صلاحية عرض النشاط.",
ariaLabel: "تغذية النشاط",
typeCreate: "إنشاء",
typeUpdate: "تحديث",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2798,6 +2798,7 @@ const ja = {
title: "最近の活動",
filter: "フィルター",
empty: "最近の活動はありません",
accessDenied: "アクティビティを表示する権限がありません。",
ariaLabel: "アクティビティフィード",
typeCreate: "作成",
typeUpdate: "更新",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2795,6 +2795,7 @@ const ko = {
title: "최근 활동",
filter: "필터",
empty: "최근 활동 없음",
accessDenied: "활동을 볼 권한이 없습니다.",
ariaLabel: "활동 피드",
typeCreate: "생성",
typeUpdate: "업데이트",
Expand Down
Loading
Loading