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
13 changes: 13 additions & 0 deletions .changeset/12081-activity-bell-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': patch
---

The console asks for recent activity only when the signed-in user may read it, and a failed read no longer shows as "No recent activity" (objectui#12081, item 8).

The header bell's Activity tab, the `global:notifications` page block and Home's activity card share one `sys_activity` read. It went out for every signed-in user on every page. A user whose permission sets grant no read on `sys_activity` (every non-admin on a deployment whose app sets do not name it) got `403` each time, and all three surfaces then said "No recent activity".

- **No read without the grant.** The feed now checks the user's read permission on `sys_activity` from the permissions the console already loads (`/auth/me/permissions`). A user without it gets no request. The bell offers no Activity tab, and Home shows no activity card. When permissions are not loaded (no permission provider is mounted, or the permissions request failed), the read still goes out as before.
- **A failed read says so.** The Activity tab and Home's card now show "An unexpected error occurred." when the read fails, a refusal included, and "Loading…" while it is in flight. "No recent activity" appears only when the read has answered with no rows.
- **The previous user's rows are not reused.** The server returns only the activity on records the reader can open. A sign-out keeps the console running, so the cached rows could be shown to the next user who signed in on the same tab. The cache is now kept per signed-in user.

Clause-②: no. Nothing is added to or removed from the package entry, and no locale key is added. `AppHeader`'s props are unchanged: rows a host passes as `activities` are shown as an answer, as before.
18 changes: 13 additions & 5 deletions packages/app-shell/src/console/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
resolveHostAppSegment,
resolveNotificationTarget,
} from '../../utils/index.js';
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
import { Empty, EmptyTitle, EmptyDescription, Button, cn } from '@object-ui/components';
import { Sparkles, UploadCloud, MessageSquareText, Hammer, LayoutTemplate } from 'lucide-react';
import { usePendingDrafts } from '../../preview/usePendingDrafts.js';
import { usePublishAllDrafts } from '../../preview/usePublishAllDrafts.js';
Expand Down Expand Up @@ -244,7 +244,7 @@ export function HomePage() {
const { favorites } = useFavorites();
const { user } = useAuth();
const { isAdmin, isResolved: isAdminResolved } = useWorkspaceAdminStatus();
const { pendingApprovalsCount, notifications, unreadTopicCount, notificationsStatus, activities } =
const { pendingApprovalsCount, notifications, unreadTopicCount, notificationsStatus, activity } =
useHomeInbox();
// Home renders OUTSIDE the `/apps/:appName/*` router, so there is no
// `params.appName` to read — `currentAppName` (published by ConsoleLayout on
Expand Down Expand Up @@ -599,10 +599,18 @@ export function HomePage() {
/>
</div>

{/* Continue where you left off + ambient activity */}
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
{/* Continue where you left off + ambient activity. The activity column
exists only for a caller who may read `sys_activity`
(objectui#12081) — `HomeActivity` renders nothing otherwise, and an
empty 360px column would be a hole where the card was. */}
<div
className={cn(
'grid grid-cols-1 items-start gap-6',
activity.readable && 'lg:grid-cols-[minmax(0,1fr)_360px]',
)}
>
<HomeContinue items={recentApps} onOpen={(href) => navigate(href)} t={t} labelOf={recentLabel} />
<HomeActivity items={activities} onViewAll={() => navigate(`/apps/${hostAppSegment}/sys_activity`)} t={t} />
<HomeActivity activity={activity} onViewAll={() => navigate(`/apps/${hostAppSegment}/sys_activity`)} t={t} />
</div>
</div>
</div>
Expand Down
41 changes: 36 additions & 5 deletions packages/app-shell/src/console/home/HomeRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
FileText, Database, LayoutDashboard, File, CircleAlert,
} from 'lucide-react';
import { useDisplayLocale } from '@object-ui/i18n';
import type { ActivityItem } from '../../layout/ActivityFeed.js';
import type { ActivityFeedReading } from '../../hooks/sharedUserFeeds.js';
import type { HomeInboxStatus, HomeNotification } from '../../hooks/useHomeInbox.js';
import type { RecentItem } from '../../hooks/useRecentItems.js';
import type { RecentItemLabelResolver } from '../../hooks/useRecentItemLabel.js';
Expand Down Expand Up @@ -279,15 +279,46 @@ export function HomeContinue({
);
}

export function HomeActivity({ items, onViewAll, t }: { items: ActivityItem[]; onViewAll: () => void; t: TFn }) {
/**
* Home's activity card. Takes the feed's whole reading, not its rows
* (objectui#12081): "No recent activity" is an ASSERTION about the feed, made
* only once it has answered — the rule `HomeActionCenter` above applies to
* the inbox (#4235). The rows alone could not tell a refused `sys_activity`
* read from an empty feed, so every non-admin was told there was none.
*
* Renders nothing for a caller who may not read `sys_activity`
* (`readable: false`): no card, no empty claim, and no drill into a list page
* that answers them `403`. `HomePage` drops the column with it.
*/
export function HomeActivity({ activity, onViewAll, t }: { activity: ActivityFeedReading; onViewAll: () => void; t: TFn }) {
// The display locale, as `HomeActionCenter` above (objectui#10668).
const displayLocale = useDisplayLocale();
if (!activity.readable) return null;
const items = activity.value;
return (
<Card icon={Activity} title={t('sidebar.activityFeed', { defaultValue: 'Activity feed' })}>
{/* Beside the list, not only in place of it, as on the action centre. */}
{activity.status !== 'ready' && (
<div
className="flex items-center gap-2 pb-2 text-sm text-muted-foreground"
data-testid="home-activity-unanswered"
>
{activity.status === 'error' ? (
<>
<CircleAlert className="h-4 w-4 text-amber-500" />
{t('errors.unknown', { defaultValue: 'An unexpected error occurred.' })}
</>
) : (
t('common.loading', { defaultValue: 'Loading…' })
)}
</div>
)}
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t('layout.activityFeed.empty', { defaultValue: 'No recent activity' })}
</p>
activity.status === 'ready' && (
<p className="text-sm text-muted-foreground">
{t('layout.activityFeed.empty', { defaultValue: 'No recent activity' })}
</p>
)
) : (
<ul className="flex flex-col gap-2.5">
{items.slice(0, 5).map((a) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ let approvalsFixture = 0;
vi.mock('../../../hooks/sharedUserFeeds', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useSharedPendingApprovalsCount: () => approvalsFixture,
useHumanActivityFeed: () => [],
useHumanActivityFeed: () => ({ value: [], status: 'ready', readable: true }),
}));

import { useHomeInbox } from '../../../hooks/useHomeInbox';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* 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#12081 item 8 — Home's activity card, the second consumer of the
* shared activity feed.
*
* It took the feed's rows alone, so a refused `sys_activity` read reached it as
* `[]` and it told every non-admin "No recent activity". It now takes the
* feed's whole reading, and these cases pin each state of it: no card for a
* caller without the grant, a failure that says so, a pending read that claims
* nothing, and — the control — the earned empty copy once the feed answers.
*/
import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { HomeActivity } from '../HomeRail';
import type { ActivityFeedReading } from '../../../hooks/sharedUserFeeds';

const t = (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key);

const EMPTY_COPY = 'No recent activity';
const ERROR_COPY = 'An unexpected error occurred.';
// U+2026, matching `common.loading` in the en pack.
const LOADING_COPY = 'Loading…';

const ROW = {
id: 'a1',
type: 'update' as const,
objectName: 'crm_lead',
user: 'Li Si',
description: 'updated the lead',
timestamp: '2026-10-10T08:00:00Z',
};

const card = (activity: ActivityFeedReading) =>
render(<HomeActivity activity={activity} onViewAll={vi.fn()} t={t} />);

describe("Home's activity card reads the feed's status and grant (objectui#12081)", () => {
it('renders nothing for a caller who may not read sys_activity', () => {
const { container } = card({ value: [], status: 'idle', readable: false });
expect(container).toBeEmptyDOMElement();
});

it('a failed read says so, and does not say "No recent activity"', () => {
card({ value: [], status: 'error', readable: true });
expect(screen.getByTestId('home-activity-unanswered')).toHaveTextContent(ERROR_COPY);
expect(screen.queryByText(EMPTY_COPY)).not.toBeInTheDocument();
});

it('a read still in flight claims nothing about the feed', () => {
card({ value: [], status: 'loading', readable: true });
expect(screen.getByTestId('home-activity-unanswered')).toHaveTextContent(LOADING_COPY);
expect(screen.queryByText(EMPTY_COPY)).not.toBeInTheDocument();
});

it('a failed re-read keeps the rows it had, beside the notice', () => {
card({ value: [ROW], status: 'error', readable: true });
expect(screen.getByText('updated the lead')).toBeInTheDocument();
expect(screen.getByTestId('home-activity-unanswered')).toHaveTextContent(ERROR_COPY);
});

it('an answered empty feed still says "No recent activity" (the control)', () => {
card({ value: [], status: 'ready', readable: true });
expect(screen.getByText(EMPTY_COPY)).toBeInTheDocument();
expect(screen.queryByTestId('home-activity-unanswered')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ vi.mock('../../../hooks/useHomeInbox', () => ({
pendingApprovalsCount: 0,
notifications: [],
unreadTopicCount: 0,
activities: [],
activity: { value: [], status: 'ready', readable: true },
}),
}));
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ vi.mock('../../../hooks/useHomeInbox', () => ({
pendingApprovalsCount: 3,
notifications: [],
unreadTopicCount: 0,
activities: [],
activity: { value: [], status: 'ready', readable: true },
}),
}));
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ vi.mock('../../../hooks/useHomeInbox', () => ({
pendingApprovalsCount: 0,
notifications: [],
unreadTopicCount: 0,
activities: [],
activity: { value: [], status: 'ready', readable: true },
}),
}));
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ vi.mock('../../../hooks/useHomeInbox', () => ({
pendingApprovalsCount: 0,
notifications: [],
unreadTopicCount: 0,
activities: [],
activity: { value: [], status: 'ready', readable: true },
}),
}));
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ vi.mock('../../../hooks/useHomeInbox', () => ({
pendingApprovalsCount: 0,
notifications: notificationsFixture,
unreadTopicCount: notificationsFixture.length,
activities: activitiesFixture,
activity: { value: activitiesFixture, status: 'ready', readable: true },
}),
}));
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ vi.mock('../../../hooks/useHomeInbox', () => ({
pendingApprovalsCount: 0,
notifications: [],
unreadTopicCount: 0,
activities: [],
activity: { value: [], status: 'ready', readable: true },
}),
}));
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ vi.mock('../../../hooks/useHomeInbox', () => ({
pendingApprovalsCount: 0,
notifications: notificationsFixture,
unreadTopicCount: notificationsFixture.length,
activities: [],
activity: { value: [], status: 'ready', readable: true },
}),
}));
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,20 @@ function railUnder(locale: string) {
</div>
<div data-testid="activity">
<HomeActivity
items={[
{
id: 'a1',
type: 'update',
objectName: 'account',
user: 'Priya',
description: 'updated Northwind',
timestamp: threeDaysAgo(),
},
]}
activity={{
value: [
{
id: 'a1',
type: 'update',
objectName: 'account',
user: 'Priya',
description: 'updated Northwind',
timestamp: threeDaysAgo(),
},
],
status: 'ready',
readable: true,
}}
onViewAll={() => {}}
t={t}
/>
Expand Down
Loading
Loading