diff --git a/.changeset/12602-home-my-work-i18n.md b/.changeset/12602-home-my-work-i18n.md new file mode 100644 index 0000000000..c8a73bb395 --- /dev/null +++ b/.changeset/12602-home-my-work-i18n.md @@ -0,0 +1,10 @@ +--- +'@object-ui/i18n': minor +--- + +Locale keys for the console home's "My work" section (objectui#12602), in all ten packs. + +- **Added:** `home.myWork.title`, `home.myWork.openInbox`, `home.myWork.viewAll`, `home.myWork.approvals`, `home.myWork.approvalsEmpty`, `home.myWork.notifications` and `home.myWork.notificationsEmpty`. For the pending-drafts row: `home.pendingDrafts.title` (a count family), `home.pendingDrafts.inPackage`, `home.pendingDrafts.waiting` and `home.pendingDrafts.nameSeparator`. +- **Removed:** `home.actionCenter.title`, `home.actionCenter.empty` and the `home.pendingDrafts.message` count family. Their only readers were the retired action-centre card and the retired drafts banner, and nothing in this repository reads them now. + +**Clause-②: yes.** The exported `en` pack is typed by its literal content, so its type gains the keys above and loses the removed ones. diff --git a/.changeset/12602-home-my-work.md b/.changeset/12602-home-my-work.md new file mode 100644 index 0000000000..09b68f6c4b --- /dev/null +++ b/.changeset/12602-home-my-work.md @@ -0,0 +1,14 @@ +--- +'@object-ui/app-shell': minor +--- + +The console home's "Needs your attention" card becomes the "My work" section of the approved home artboard (objectui#12602), and the pending-drafts banner above the greeting moves into it. + +- **Heading.** "My work", 15 px / 600, with a count pill: the approvals waiting on the user plus the unread notification topics, the two numbers the bell adds up for its badge. At the right, an "Open inbox" link opens the inbox page the bell's "view all" opens. +- **Two columns.** Approvals first, then Notifications, side by side from `md` up with a 12 px gap. Each column is an 8 px-radius card with a 40 px header (title, count pill, "View all") and up to three 40 px rows: a 24 px icon tile, a one-line title cut with an ellipsis, and a 12 px relative time. + - **Approvals** lists the approval requests waiting on the user, titled by the flow's label and the record's name as the approvals service serves them. Its pill is on the primary colour and is the number of those requests, from the same pending-approvals read the bell and the greeting's summary line use. A row opens that request in the Approvals inbox; "View all" opens the Approvals inbox. + - **Notifications** lists the unread inbox messages, newest first, one per title. Its pill is the unread topic count the bell shows. A row follows its link through the resolver the bell uses; "View all" opens the inbox. + - A column with nothing to list says so in one muted line ("Nothing waiting for your approval", "No unread notifications"), only once its read has answered. While a read is loading or after it fails, the column says that instead, beside any rows it still shows. +- **Not three columns.** The artboard splits notifications into "Assigned to me" and "Reminders", and marks overdue items. An inbox message carries a free-text topic and a severity, but no declared kind and no due date, so the section shows one Notifications column and no overdue marking, rather than guessing a kind from the topic or the title. +- **Who sees it.** A column is drawn only when the user may read its source. The Notifications column, and the "Open inbox" link, are left out when the loaded permissions refuse read on `sys_inbox_message`; permissions that have not loaded count as readable. The Approvals column needs a signed-in user. With neither source and no drafts to publish, the section is not drawn at all. +- **Pending drafts.** The full-width blue banner above the greeting is removed. Its replacement is a 40 px row under the "My work" heading, on the soft primary tint: a rocket icon, "N unpublished change(s)", the first three draft names (each draft's own label, or its name when it has none) and their package when they all share one, and a 28 px "Publish" button. The button runs the same publish the banner ran. When the workspace has no app yet, the row is drawn at the top of the empty state, where the banner was. diff --git a/packages/app-shell/src/console/home/HomePage.tsx b/packages/app-shell/src/console/home/HomePage.tsx index 0c1bfe8fad..a462f0cd95 100644 --- a/packages/app-shell/src/console/home/HomePage.tsx +++ b/packages/app-shell/src/console/home/HomePage.tsx @@ -31,7 +31,8 @@ import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js'; import { HomeAppsStrip } from './HomeAppsStrip.js'; import { lastOpenedByApp } from './appLastOpened.js'; import { RecoveryPasswordReminder } from './RecoveryPasswordReminder.js'; -import { HomeActionCenter, HomeContinue, HomeActivity } from './HomeRail.js'; +import { HomeActionCenter, HomeContinue, HomeActivity, HomePendingDraftsRow } from './HomeRail.js'; +import { resolveI18nLabel } from '@objectstack/spec/ui'; import { useHomeInbox } from '../../hooks/useHomeInbox.js'; import { useNavigationContext } from '../../context/NavigationContext.js'; import { @@ -52,7 +53,7 @@ import { TooltipTrigger, cn, } from '@object-ui/components'; -import { Sparkles, UploadCloud, MessageSquareText, Hammer, LayoutGrid } from 'lucide-react'; +import { Sparkles, MessageSquareText, Hammer, LayoutGrid } from 'lucide-react'; import { usePendingDrafts } from '../../preview/usePendingDrafts.js'; import { usePublishAllDrafts } from '../../preview/usePublishAllDrafts.js'; import { resolveAiApiBase } from '../../hooks/useAiSurface.js'; @@ -234,54 +235,50 @@ function pickGreetingKey(hour: number): string { return 'home.greetingNight'; } +/** Draft names the drafts row lists before the rest go uncounted by name. */ +const DRAFT_NAMES_SHOWN = 3; + /** - * Pending-drafts banner — closes the AI magic-moment loop. After the metadata - * assistant drafts objects/views/apps (ADR-0033 draft-gated authoring), nothing - * is live until the human publishes. Without this, a user who just had AI build - * their whole system landed back on an empty-looking home with no trace of it - * and no path to publish. This surfaces the pending drafts and routes to the - * designer to review + publish. Disappears automatically once published - * (listDrafts → 0). + * The pending drafts, read for Home's drafts row (objectui#12602) — the read + * and the publish the retired banner above the greeting ran, unchanged. After + * the metadata assistant drafts objects, views or apps (ADR-0033 draft-gated + * authoring), nothing is live until a person publishes; the row surfaces them + * with a one-click publish and disappears once they are live (`_drafts` → 0). + * + * `usePendingDrafts({})` is the env-wide scope (objectui#5801): its bus + * subscription keeps the count current after later edits and after a + * publish. `usePublishAllDrafts` is the shared one-click publish, also run by + * the ADR-0037 draft-preview bar. Returns the row, or `null` with nothing to + * publish. */ -function PendingDraftsBanner({ t }: { t: (key: string, opts?: any) => string }) { - // Shared one-click publish (also used by the ADR-0037 draft-preview bar): - // packages via the probed publish-drafts path, orphans by reference, health - // surfaced in toasts. See usePublishAllDrafts for the full story. +function usePendingDraftsRow(t: (key: string, opts?: any) => string, language: string): React.ReactNode { const { publishAll, publishing } = usePublishAllDrafts(t); - // objectui#5801 — env-wide scope, from the shared pending-drafts source. - // The bus subscription inside the hook replaces both the mount-only fetch - // (which never saw later edits) and the post-publish window.location.reload - // (publishAll now emits the metadata-refresh pulse; the launcher/nav refresh - // through MetadataProvider's subscription to the same pulse). - const { count } = usePendingDrafts({}); - - const publish = async () => { - await publishAll(); - }; - + const { count, entries } = usePendingDrafts({}); if ((count ?? 0) <= 0) return null; + // The draft's own label in the console language (objectui#11862), its + // name only where `_drafts` served none. + const names = entries + .slice(0, DRAFT_NAMES_SHOWN) + .map((e) => resolveI18nLabel(e.label, language)?.trim() || e.name); + // Named only when every draft is in one package: a row naming one package + // for drafts spread over several would misplace the rest. + const packages = new Set(entries.map((e) => e.packageId)); + const [onlyPackage] = packages; return ( -
-
-
- -

- {t('home.pendingDrafts.message', { count, defaultValue: 'You have {{count}} unpublished change(s) — publish to make them live.' })} -

- -
-
-
+ void publishAll()} + t={t} + /> ); } export function HomePage() { const navigate = useNavigate(); - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const { apps, loading } = useMetadata(); const { recentItems } = useRecentItems(); // A recent entry stores identity; the rail labels it with this, on the @@ -292,12 +289,17 @@ export function HomePage() { const { isAdmin, isResolved: isAdminResolved } = useWorkspaceAdminStatus(); const { pendingApprovalsCount, + pendingApprovals, + pendingApprovalsReadable, pendingApprovalsStatus, notifications, unreadTopicCount, notificationsStatus, + notificationsReadable, activity, } = useHomeInbox(); + // The drafts row of the My-work section — `null` with nothing to publish. + const draftsRow = usePendingDraftsRow(t, language); // Home renders OUTSIDE the `/apps/:appName/*` router, so there is no // `params.appName` to read — `currentAppName` (published by ConsoleLayout on // every app mount) is the only "which app is the user in" signal available @@ -437,7 +439,13 @@ export function HomePage() { if (activeApps.length === 0) { return (
- + {/* With no app yet, the drafts row is the whole of "My work": the + moment the assistant has drafted a first app and nothing is live. */} + {draftsRow && ( +
+
{draftsRow}
+
+ )}
{/* @@ -523,7 +531,6 @@ export function HomePage() { return (
- {/* The content column (objectui#12601): 1120 px, centred, 28 px above the @@ -670,42 +677,53 @@ export function HomePage() { marketplaceEnabled={marketplaceEnabled} /> - {/* Action center — what needs the user; leads the dashboard */} -
- navigate(`/apps/${hostAppSegment}/system/approvals`)} - /* `action_url` is APP-RELATIVE (`/{object}/{id}`, ADR-0030 L5), - so it has to be hosted under an app before it is a route — - navigating it verbatim is objectui#5179, where the console - catch-all forwarded the unmatched path to `/` and the landing - resolver dropped the user on the default app's home with the - notification already marked read. `resolveNotificationTarget` - is shared with the top-bar bell so the two cannot answer this - differently. The fallback arm runs whenever a notification - carries no link at all; `?view=mine` selects the user-scoped - view, so the full page matches what the card showed - (objectui#4074). */ - onOpenNotification={(n) => { - const target = resolveNotificationTarget(n.actionUrl, hostAppSegment); - if (!target) { - navigate(`/apps/${hostAppSegment}/sys_inbox_message?view=mine`); - return; - } - if (target.kind === 'external') { - window.open(target.url, '_blank', 'noopener,noreferrer'); - return; - } - navigate(target.path); - }} - t={t} - /> -
+ {/* My work (objectui#12602) — approvals, then unread notifications, + with the pending-drafts row under its heading. */} + navigate(`/apps/${hostAppSegment}/system/approvals`)} + /* The Approvals inbox opens a request's drawer from `?request=` + (#2678), the link its notifications already carry. */ + onOpenApproval={(r) => + navigate(`/apps/${hostAppSegment}/system/approvals?request=${encodeURIComponent(r.id)}`) + } + /* The bell's own "view all" target (`InboxPopover`): `?view=mine` + selects the user-scoped view, so the full page matches what the + column showed (objectui#4074). */ + onOpenInbox={() => navigate(`/apps/${hostAppSegment}/sys_inbox_message?view=mine`)} + /* `action_url` is APP-RELATIVE (`/{object}/{id}`, ADR-0030 L5), + so it has to be hosted under an app before it is a route — + navigating it verbatim is objectui#5179, where the console + catch-all forwarded the unmatched path to `/` and the landing + resolver dropped the user on the default app's home with the + notification already marked read. `resolveNotificationTarget` + is shared with the top-bar bell so the two cannot answer this + differently. The fallback arm runs whenever a notification + carries no link at all, and opens the inbox. */ + onOpenNotification={(n) => { + const target = resolveNotificationTarget(n.actionUrl, hostAppSegment); + if (!target) { + navigate(`/apps/${hostAppSegment}/sys_inbox_message?view=mine`); + return; + } + if (target.kind === 'external') { + window.open(target.url, '_blank', 'noopener,noreferrer'); + return; + } + navigate(target.path); + }} + t={t} + /> {/* Continue where you left off + ambient activity. The activity column exists only for a caller who may read `sys_activity` diff --git a/packages/app-shell/src/console/home/HomeRail.tsx b/packages/app-shell/src/console/home/HomeRail.tsx index 87321257e4..f1099858de 100644 --- a/packages/app-shell/src/console/home/HomeRail.tsx +++ b/packages/app-shell/src/console/home/HomeRail.tsx @@ -2,8 +2,9 @@ * HomeRail * * Dashboard cards for the Home work-dashboard: - * - HomeActionCenter — "what needs me" (approvals + inbox notifications); - * the reason a business user opens Home, so it leads the page. + * - HomeActionCenter — the "My work" section: approvals waiting on the + * user, unread notifications, and the pending-drafts row + * (`HomePendingDraftsRow`) — what needs the user (objectui#12602). * - HomeContinue — recent items, compact "pick up where you left off". * - HomeActivity — recent human activity feed (ambient context). * @@ -13,12 +14,13 @@ * @module */ import { - CheckSquare, Activity, ArrowRight, CheckCheck, Bell, Clock, - FileText, Database, LayoutDashboard, File, CircleAlert, + Activity, ArrowRight, Bell, Check, Clock, + FileText, Database, LayoutDashboard, File, CircleAlert, Rocket, } from 'lucide-react'; import { useDisplayLocale } from '@object-ui/i18n'; +import { Button, cn } from '@object-ui/components'; import type { ActivityFeedReading } from '../../hooks/sharedUserFeeds.js'; -import type { HomeInboxStatus, HomeNotification } from '../../hooks/useHomeInbox.js'; +import type { HomeInboxStatus, HomeNotification, PendingApprovalRequest } from '../../hooks/useHomeInbox.js'; import type { RecentItem } from '../../hooks/useRecentItems.js'; import type { RecentItemLabelResolver } from '../../hooks/useRecentItemLabel.js'; import { ActivityObjectLabel } from '../../layout/ActivityObjectLabel.js'; @@ -97,44 +99,296 @@ function Row({ ); } +/** Rows a My-work column lists — a preview; its pill carries the total. */ +const MY_WORK_ROWS = 3; + +/** The section's count pills: 18 px, 11 px / 600 (objectui#12602). */ +function CountPill({ count, primary, testId }: { count: number; primary?: boolean; testId?: string }) { + return ( + + {count} + + ); +} + +/** + * One 40 px row of a My-work column: a 24 px icon tile, the one-line title + * with an ellipsis, and a 12 px muted time at the right. + */ +function WorkRow({ + icon: Icon, + tileClass, + label, + meta, + onClick, + testId, +}: { + icon: React.ComponentType<{ className?: string }>; + tileClass: string; + label: string; + meta?: string; + onClick: () => void; + testId?: string; +}) { + return ( +
  • + +
  • + ); +} + +/** + * A column's notice line: its empty line, or why it has no answer. "Nothing waiting" is an + * ASSERTION about the source, so it is made only once the source has answered + * (#4235): before that the column says it is loading, and after a failed read + * it says the read failed — never the affirmative empty copy. + */ +function ColumnNotice({ + status, + empty, + t, + testId, +}: { + status: HomeInboxStatus; + empty: string; + t: TFn; + testId: string; +}) { + return ( +
    + {status === 'ready' ? ( + empty + ) : status === 'error' ? ( + <> + + {t('errors.unknown', { defaultValue: 'An unexpected error occurred.' })} + + ) : ( + t('common.loading', { defaultValue: 'Loading…' }) + )} +
    + ); +} + /** - * "You're all caught up" is an ASSERTION about the user's inbox, so it may only - * be made once the inbox has answered (#4235). + * A column's rows and, where it owes one, its notice line. * - * `notifications` arriving empty says nothing on its own: until #4235 the hook - * behind it swallowed every failed read to `[]`, so a `403 PERMISSION_DENIED` - * on `sys_inbox_message` (objectstack#7344, browser-measured for every non-admin - * persona) reached this component wearing the exact shape of an empty inbox — - * and the panel cheerfully told a user with nine unread messages that there was - * nothing to do, with no badge. `notificationsStatus` is the missing bit, and - * gating on it is why that pair is now unreachable: an unanswered read renders - * the quiet notice below, never the affirmative copy. + * Rendered ALONGSIDE the rows, not only instead of them: when the source has + * rows in hand and its next read failed, the column is showing a stale + * answer, and saying so is the same honesty the empty case owes (#4225). The + * empty line is gated on the TOTAL, not on the rows on show: an unread + * message with no title cannot be listed, and the column must not call the + * inbox empty while the bell badges it. + */ +function ColumnBody({ + rows, + total, + status, + empty, + t, + testId, +}: { + rows: React.ReactNode[]; + total: number; + status: HomeInboxStatus; + empty: string; + t: TFn; + testId: string; +}) { + const notice = + status !== 'ready' || (rows.length === 0 && total === 0) ? ( + + ) : null; + return ( + <> + {rows.length > 0 &&
      {rows}
    } + {notice} + + ); +} + +/** A My-work column: an 8 px-radius card with a 40 px header and its rows. */ +function WorkColumn({ + title, + count, + primaryPill, + onViewAll, + t, + testId, + children, +}: { + title: string; + count: number; + primaryPill?: boolean; + onViewAll: () => void; + t: TFn; + testId: string; + children: React.ReactNode; +}) { + return ( +
    +
    +

    + {title} + {count > 0 && } +

    + +
    + {children} +
    + ); +} + +/** + * The pending-drafts row of the My-work section (objectui#12602), in place of + * the full-width banner that sat above the greeting: one 40 px row on the + * soft primary tint — the count, the first draft names and their package, and + * a 28 px "Publish" button that runs the publish the banner ran + * (`usePublishAllDrafts`, handed in as `onPublish`). + */ +export function HomePendingDraftsRow({ + count, + names, + packageName, + publishing, + onPublish, + t, +}: { + count: number; + /** The first draft names to list — the caller caps them. */ + names: string[]; + /** The package every listed draft belongs to, when they share one. */ + packageName?: string; + publishing: boolean; + onPublish: () => void; + t: TFn; +}) { + const joined = names.join(t('home.pendingDrafts.nameSeparator', { defaultValue: ', ' })); + const detail = !joined + ? undefined + : packageName + ? t('home.pendingDrafts.inPackage', { + names: joined, + package: packageName, + defaultValue: '{{names}} waiting to publish in {{package}}', + }) + : t('home.pendingDrafts.waiting', { names: joined, defaultValue: '{{names}} waiting to publish' }); + return ( +
    + +

    + + {t('home.pendingDrafts.title', { count, defaultValue: '{{count}} unpublished change(s)' })} + + {detail && · {detail}} +

    + +
    + ); +} + +/** + * Home's "My work" section (objectui#12602, section ③ of the four-part home): + * a heading with the total and a link to the inbox, the pending-drafts row + * when there is one, then one column per source — the approvals waiting on + * the user first, then the unread notifications. + * + * ## Two columns, not three + * + * The design splits notifications into "Assigned to me" and "Reminders". An + * inbox row cannot be sorted that way: `sys_inbox_message` carries a free-text + * `topic` and a `severity`, and no declared kind that says an item was + * assigned to the user, nor a due date that could read "overdue". So the + * section ships the two-column form the card rules for that case — Approvals + * and Notifications — rather than guessing a kind from a topic or a title. + * + * ## The numbers are the bell's * - * Same rule #4300 landed for the app list ("an unloadable app list is UNKNOWN, - * not 'no default app'"), and the same status vocabulary. + * The approvals pill is `pendingApprovalsCount`, the length of the one shared + * approvals read the bell badges (objectui#4329); the notifications pill is + * `unreadTopicCount`, the bell's own fold of the unread inbox. The heading's + * total is their sum — the bell's badge. The rows are previews of those + * totals: the first three of each. + * + * ## Who sees it + * + * A column is drawn only for a source the caller may read + * (`pendingApprovalsReadable`, `notificationsReadable` — see `useHomeInbox`), + * and the section renders nothing when the caller may read neither and has no + * drafts row: no heading over an empty space, no links into pages that answer + * `403`. */ export function HomeActionCenter({ + pendingApprovals, pendingApprovalsCount, + pendingApprovalsStatus, + pendingApprovalsReadable, notifications, unreadTopicCount, notificationsStatus, + notificationsReadable, + draftsRow, onOpenApprovals, + onOpenApproval, + onOpenInbox, onOpenNotification, t, }: { + /** The PREVIEW: the first approval requests, capped by `useHomeInbox`. */ + pendingApprovals: PendingApprovalRequest[]; + /** The TOTAL waiting — the bell's addend, not the preview's length. */ pendingApprovalsCount: number; - /** The PREVIEW: newest-first, one row per title, capped by `useHomeInbox`. */ + /** Whether the approvals read answered — the empty line waits for `ready`. */ + pendingApprovalsStatus: HomeInboxStatus; + /** Whether the caller has an approvals inbox to read at all. */ + pendingApprovalsReadable: boolean; + /** The PREVIEW: newest-first unread rows, one per title, capped by `useHomeInbox`. */ notifications: HomeNotification[]; /** - * The TOTAL waiting in the inbox — every unread topic, not just the ones this - * card has room for (#4329). - * - * Required, and separate from `notifications` for the same reason - * `notificationsStatus` is: `notifications.length` was the badge until this - * card learned the difference, which made the badge report the size of a - * capped list. Nine unread showed "9" on the bell and "5" here, on one page, - * about one set of rows. A call site that cannot say how much is waiting must - * not be able to badge its own preview length by saying nothing. + * The TOTAL waiting in the inbox — every unread topic, not just the ones the + * column has room for (#4329). Required, and separate from `notifications`, + * so a call site cannot badge its preview's length by saying nothing. */ unreadTopicCount: number; /** @@ -143,85 +397,117 @@ export function HomeActionCenter({ * saying nothing. */ notificationsStatus: HomeInboxStatus; + /** Whether the caller may read the inbox at all. */ + notificationsReadable: boolean; + /** The pending-drafts row, when there are drafts to publish. */ + draftsRow?: React.ReactNode; onOpenApprovals: () => void; + onOpenApproval: (request: PendingApprovalRequest) => void; + onOpenInbox: () => void; onOpenNotification: (n: HomeNotification) => void; t: TFn; }) { // Relative times read the DISPLAY locale, never the UI language // (objectui#10668), as the Marketplace's and Studio home's already do. const displayLocale = useDisplayLocale(); - // "How much needs you", which is the question the badge asks and the question - // the bell answers with the same number. The list below is a preview of it — - // fewer rows than this whenever the cap or the title fold bites, and that - // gap is the point rather than a defect: badge = total, list = preview. - const total = pendingApprovalsCount + unreadTopicCount; - const answered = notificationsStatus === 'ready'; + if (!pendingApprovalsReadable && !notificationsReadable && !draftsRow) return null; + // "How much needs you" — the bell's badge, the same two addends. + const total = + (pendingApprovalsReadable ? pendingApprovalsCount : 0) + (notificationsReadable ? unreadTopicCount : 0); + const columns = (pendingApprovalsReadable ? 1 : 0) + (notificationsReadable ? 1 : 0); return ( - - {/* - Rendered ALONGSIDE the list, not only instead of it: when approvals are - known and the inbox read failed, the panel is showing half an answer, - and saying so is the same honesty the empty case owes. - */} - {!answered && ( -
    - {notificationsStatus === 'error' ? ( - <> - - {t('errors.unknown', { defaultValue: 'An unexpected error occurred.' })} - - ) : ( - t('common.loading', { defaultValue: 'Loading…' }) +
    +
    +

    + {t('home.myWork.title', { defaultValue: 'My work' })} + {total > 0 && } +

    + {notificationsReadable && ( + + )} +
    + {draftsRow &&
    0 ? 'mb-2.5' : undefined}>{draftsRow}
    } + {columns > 0 && ( +
    + {pendingApprovalsReadable && ( + + ( + onOpenApproval(r)} + testId="home-my-work-approval-row" + /> + ))} + total={pendingApprovalsCount} + status={pendingApprovalsStatus} + empty={t('home.myWork.approvalsEmpty', { defaultValue: 'Nothing waiting for your approval' })} + t={t} + testId="home-my-work-approvals-notice" + /> + )} -
    - )} - {/* - Gated on the TOTAL, not on the rows on show: "You're all caught up" is a - claim about the inbox, so it may only be made when the inbox is empty — - never merely because this card had nothing renderable to list. (The one - state where the two differ is an unread message with no title at all, - which the list cannot render: the card then shows its badge and no row, - rather than telling the user they are caught up while the bell above - badges the same message.) - */} - {total === 0 ? ( - answered && ( -
    - - {t('home.actionCenter.empty', { defaultValue: "You're all caught up" })} -
    - ) - ) : ( -
      - {pendingApprovalsCount > 0 && ( - } - onClick={onOpenApprovals} - testId="home-action-approvals" - /> + {notificationsReadable && ( + + ( + onOpenNotification(n)} + testId="home-my-work-notification-row" + /> + ))} + total={unreadTopicCount} + status={notificationsStatus} + empty={t('home.myWork.notificationsEmpty', { defaultValue: 'No unread notifications' })} + t={t} + testId="home-my-work-notifications-notice" + /> + )} - {notifications.map((n) => ( - onOpenNotification(n)} - /> - ))} -
    +
    )} -
    + ); } +/** + * An approval row's one line: the flow's label, then the record's name — the + * service's display enrichment, as the Approvals inbox shows it. The flow's + * machine name stands in only when the service resolved no label for it. + */ +function approvalTitle(r: PendingApprovalRequest): string { + return [r.processLabel || r.processName, r.recordTitle].filter(Boolean).join(' · '); +} + const RECENT_ICON: Record> = { object: Database, record: FileText, diff --git a/packages/app-shell/src/console/home/__tests__/HomeActionCenter.unansweredInbox.test.tsx b/packages/app-shell/src/console/home/__tests__/HomeActionCenter.unansweredInbox.test.tsx index 42b2dc2053..f69ced85cc 100644 --- a/packages/app-shell/src/console/home/__tests__/HomeActionCenter.unansweredInbox.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomeActionCenter.unansweredInbox.test.tsx @@ -100,7 +100,12 @@ vi.mock('../../../hooks/sharedUserFeeds', async (importOriginal) => ({ ...(await importOriginal>()), // `useHomeInbox` reads the approvals feed WITH its status (objectui#12601); // the dial is still the count, always an answer here. - useSharedPendingApprovals: () => ({ value: approvalsFixture, status: 'ready' }), + // objectui#12602: the feed hands Home the requests themselves, and the + // count is their number. Still always an answer here. + useSharedPendingApprovalRequests: () => ({ + value: Array.from({ length: approvalsFixture }, (_, i) => ({ id: `apr_${i + 1}`, processName: 'p' })), + status: 'ready', + }), useHumanActivityFeed: () => ({ value: [], status: 'ready', readable: true }), })); @@ -110,15 +115,20 @@ import { HomeActionCenter } from '../HomeRail'; /** Exactly `HomePage`'s wiring of the two — the seam the card indicts. */ function HomeActionCenterHost() { - const { pendingApprovalsCount, notifications, notificationsStatus, unreadTopicCount } = - useHomeInbox(); + const inbox = useHomeInbox(); return ( {}} + onOpenApproval={() => {}} + onOpenInbox={() => {}} onOpenNotification={() => {}} t={(key: string, options?: any) => String(options?.defaultValue ?? key)} /> @@ -136,7 +146,9 @@ const NINE_UNREAD = Array.from({ length: 9 }, (_, i) => ({ created_at: `2026-08-0${i + 1}T09:00:00Z`, })); -const CAUGHT_UP = "You're all caught up"; +// The notifications column's empty line (objectui#12602) — the affirmative +// copy, earned only by an answer. +const CAUGHT_UP = 'No unread notifications'; const ERROR_COPY = 'An unexpected error occurred.'; // U+2026, matching `common.loading` in the en pack (objectui#3878). const LOADING_COPY = 'Loading…'; @@ -166,11 +178,14 @@ function objectMissing(): Promise { return Promise.reject(err); } -/** The card's badge: rendered by `Card` only when the count is > 0. */ +/** The section's total pill: rendered only when the count is > 0. */ function badgeText(): string | null { - const heading = screen.getByText('Needs your attention'); - const badge = heading.parentElement?.querySelector('span.tabular-nums'); - return badge ? badge.textContent : null; + return screen.queryByTestId('home-my-work-count')?.textContent ?? null; +} + +/** The column says the read is unanswered: still loading, or failed. */ +function unanswered(): boolean { + return screen.queryByText(ERROR_COPY) !== null || screen.queryByText(LOADING_COPY) !== null; } beforeEach(() => { @@ -193,9 +208,8 @@ describe('Home action centre — the affirmative empty state needs an ANSWER (#4 render(); - await waitFor(() => expect(screen.getByTestId('home-action-unanswered')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText(ERROR_COPY)).toBeInTheDocument()); expect(screen.queryByText(CAUGHT_UP)).not.toBeInTheDocument(); - expect(screen.getByText(ERROR_COPY)).toBeInTheDocument(); }); it('does not claim it while the session is still settling (no user yet)', async () => { @@ -217,7 +231,7 @@ describe('Home action centre — the affirmative empty state needs an ANSWER (#4 render(); expect(screen.queryByText(CAUGHT_UP)).not.toBeInTheDocument(); - expect(screen.getByTestId('home-action-unanswered')).toBeInTheDocument(); + expect(screen.getByText(LOADING_COPY)).toBeInTheDocument(); }); it('says so even when the approvals half DID answer', async () => { @@ -230,7 +244,7 @@ describe('Home action centre — the affirmative empty state needs an ANSWER (#4 render(); await waitFor(() => expect(screen.getByText(ERROR_COPY)).toBeInTheDocument()); - expect(screen.getByTestId('home-action-approvals')).toBeInTheDocument(); + expect(screen.getByTestId('home-my-work-approvals-count')).toHaveTextContent('2'); expect(screen.queryByText(CAUGHT_UP)).not.toBeInTheDocument(); }); @@ -242,7 +256,7 @@ describe('Home action centre — the affirmative empty state needs an ANSWER (#4 render(); await waitFor(() => expect(screen.getByText(CAUGHT_UP)).toBeInTheDocument()); - expect(screen.queryByTestId('home-action-unanswered')).not.toBeInTheDocument(); + expect(unanswered()).toBe(false); expect(badgeText()).toBeNull(); }); @@ -280,10 +294,11 @@ describe('Home action centre — nine unread rows are listed and badged (#4235)' // how one page came to show 9 on the bell and 5 here for one question — // the badge is the total waiting now, the list its newest few. The // rows-are-listed-and-badged claim this case exists to make is unchanged. - expect(screen.getAllByText(/Approval request \d+ needs your decision/)).toHaveLength(5); + // The column shows three of them since objectui#12602's My-work section. + expect(screen.getAllByText(/Approval request \d+ needs your decision/)).toHaveLength(3); expect(badgeText()).toBe('9'); expect(screen.queryByText(CAUGHT_UP)).not.toBeInTheDocument(); - expect(screen.queryByTestId('home-action-unanswered')).not.toBeInTheDocument(); + expect(unanswered()).toBe(false); }); it('reads the ADR-0030 `mine` window — user-scoped, newest first', async () => { @@ -314,9 +329,12 @@ describe('Home action centre — nine unread rows are listed and badged (#4235)' render(); - await waitFor(() => expect(screen.getByTestId('home-action-approvals')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId('home-my-work-approvals-count')).toBeInTheDocument()); expect(badgeText()).toBe('3'); - expect(screen.queryByText(CAUGHT_UP)).not.toBeInTheDocument(); - expect(screen.queryByTestId('home-action-unanswered')).not.toBeInTheDocument(); + // Each column speaks for its own source (objectui#12602): the inbox IS + // empty, so its column says so, while the approvals column claims nothing. + expect(screen.getByText(CAUGHT_UP)).toBeInTheDocument(); + expect(screen.queryByText('Nothing waiting for your approval')).not.toBeInTheDocument(); + expect(unanswered()).toBe(false); }); }); diff --git a/packages/app-shell/src/console/home/__tests__/HomePage.aiStudioDisabled.test.tsx b/packages/app-shell/src/console/home/__tests__/HomePage.aiStudioDisabled.test.tsx index 3ef1e4f2c2..03808883e5 100644 --- a/packages/app-shell/src/console/home/__tests__/HomePage.aiStudioDisabled.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomePage.aiStudioDisabled.test.tsx @@ -109,6 +109,11 @@ vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites vi.mock('../../../hooks/useHomeInbox', () => ({ useHomeInbox: () => ({ pendingApprovalsCount: 0, + // objectui#12602: the approvals preview, and whether each source is + // the caller's to read — the My-work section draws a column per source. + pendingApprovals: [], + pendingApprovalsReadable: true, + notificationsReadable: true, notifications: [], unreadTopicCount: 0, activity: { value: [], status: 'ready', readable: true }, @@ -147,7 +152,7 @@ const serverConfig = (features: Record) => ({ * were wrong with that. * * `HomePage` reaches more than one endpoint. Besides the config read, every - * render mounts `PendingDraftsBanner`, whose `usePendingDrafts({})` fetches + * render mounts `usePendingDraftsRow`, whose `usePendingDrafts({})` fetches * `GET /api/v1/meta/_drafts` with the GLOBAL `fetch` — `usePendingDrafts.ts:48`, * no `apiFetch` seam anywhere on the path — from its mount effect * (`usePendingDrafts.ts:116` via `refresh` at `:90`). The sink handed THAT reader @@ -168,7 +173,7 @@ const serverConfig = (features: Record) => ({ * * `_drafts` answers a known-EMPTY ledger in the `{ drafts: [...] }` envelope * `fetchPendingDrafts` reads. Empty rather than seeded is load-bearing: - * `PendingDraftsBanner` renders `null` for both `count === null` (what the sink's + * `usePendingDraftsRow` renders `null` for both `count === null` (what the sink's * unparseable answer produced) and `count === 0`, so no assertion in this file * moves. Routes are matched on the PATHNAME because the hook appends a * `?packageId=` scope for package-bound callers; the full url is what is recorded. diff --git a/packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx b/packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx index 142013c06b..56e1eaf9e8 100644 --- a/packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx @@ -98,6 +98,11 @@ vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites vi.mock('../../../hooks/useHomeInbox', () => ({ useHomeInbox: () => ({ pendingApprovalsCount: 3, + // objectui#12602: the approvals preview, and whether each source is + // the caller's to read — the My-work section draws a column per source. + pendingApprovals: [], + pendingApprovalsReadable: true, + notificationsReadable: true, notifications: [], unreadTopicCount: 0, activity: { value: [], status: 'ready', readable: true }, @@ -130,7 +135,7 @@ vi.mock('../../../runtime-config', () => ({ import { HomePage } from '../HomePage'; /* ── The `_drafts` double (objectui#7307) ───────────────────────────────────── - * Every render of `HomePage` below mounts `PendingDraftsBanner`, which reads the + * Every render of `HomePage` below mounts `usePendingDraftsRow`, which reads the * env-wide pending-draft count through `usePendingDrafts({})`. That hook fetches * `GET /api/v1/meta/_drafts` with the GLOBAL `fetch` — `usePendingDrafts.ts:48`, * no `apiFetch` seam anywhere on the path — from its mount effect @@ -151,7 +156,7 @@ import { HomePage } from '../HomePage'; * ledger, in the `{ drafts: [...] }` envelope `fetchPendingDrafts` reads (the one * `MetadataClient.listDrafts` pins for this endpoint; the bare-array and * `{ data: { drafts } }` shapes parse to the same rows). Empty rather than seeded - * is load-bearing: `PendingDraftsBanner` renders `null` when `(count ?? 0) <= 0`, + * is load-bearing: `usePendingDraftsRow` renders `null` when `(count ?? 0) <= 0`, * and the failing request produced `count === null` — so an empty ledger yields * byte-identical output to what these cases have always rendered, while a seeded * one would add a banner and a `pending-drafts-publish` button to every case's @@ -205,7 +210,7 @@ const app = (name: string, extra: Record = {}) => ({ name, labe async function clickApprovals() { const user = userEvent.setup(); render(); - await user.click(screen.getByTestId('home-action-approvals')); + await user.click(screen.getByTestId('home-my-work-approvals-view-all')); expect(navigateMock).toHaveBeenCalledTimes(1); return navigateMock.mock.calls[0][0] as string; } @@ -264,6 +269,6 @@ describe('HomePage pending-approvals target (objectstack#7231)', () => { currentAppNameFixture = undefined; render(); - expect(screen.queryByTestId('home-action-approvals')).toBeNull(); + expect(screen.queryByTestId('home-my-work')).toBeNull(); }); }); diff --git a/packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx b/packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx index 0f5d420aee..b11101c6cc 100644 --- a/packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx @@ -106,6 +106,11 @@ vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites vi.mock('../../../hooks/useHomeInbox', () => ({ useHomeInbox: () => ({ pendingApprovalsCount: 0, + // objectui#12602: the approvals preview, and whether each source is + // the caller's to read — the My-work section draws a column per source. + pendingApprovals: [], + pendingApprovalsReadable: true, + notificationsReadable: true, notifications: [], unreadTopicCount: 0, activity: { value: [], status: 'ready', readable: true }, @@ -138,7 +143,7 @@ vi.mock('../../../runtime-config', () => ({ import { HomePage } from '../HomePage'; /* ── The `_drafts` double (objectui#7307) ───────────────────────────────────── - * Every render of `HomePage` below mounts `PendingDraftsBanner`, which reads the + * Every render of `HomePage` below mounts `usePendingDraftsRow`, which reads the * env-wide pending-draft count through `usePendingDrafts({})`. That hook fetches * `GET /api/v1/meta/_drafts` with the GLOBAL `fetch` — `usePendingDrafts.ts:48`, * no `apiFetch` seam anywhere on the path — from its mount effect @@ -159,7 +164,7 @@ import { HomePage } from '../HomePage'; * ledger, in the `{ drafts: [...] }` envelope `fetchPendingDrafts` reads (the one * `MetadataClient.listDrafts` pins for this endpoint; the bare-array and * `{ data: { drafts } }` shapes parse to the same rows). Empty rather than seeded - * is load-bearing: `PendingDraftsBanner` renders `null` when `(count ?? 0) <= 0`, + * is load-bearing: `usePendingDraftsRow` renders `null` when `(count ?? 0) <= 0`, * and the failing request produced `count === null` — so an empty ledger yields * byte-identical output to what these cases have always rendered, while a seeded * one would add a banner and a `pending-drafts-publish` button to every case's diff --git a/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx b/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx index f054734ac3..43d38d6c5e 100644 --- a/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx @@ -112,6 +112,11 @@ vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ vi.mock('../../../hooks/useHomeInbox', () => ({ useHomeInbox: () => ({ pendingApprovalsCount: state.pendingApprovalsCount, + // objectui#12602: the approvals preview, and whether each source is + // the caller's to read — the My-work section draws a column per source. + pendingApprovals: [], + pendingApprovalsReadable: true, + notificationsReadable: true, pendingApprovalsStatus: state.approvalsStatus, notifications: [], unreadTopicCount: 0, diff --git a/packages/app-shell/src/console/home/__tests__/HomePage.greetingLocale-11689.test.tsx b/packages/app-shell/src/console/home/__tests__/HomePage.greetingLocale-11689.test.tsx index f02e22d0d3..0100e34309 100644 --- a/packages/app-shell/src/console/home/__tests__/HomePage.greetingLocale-11689.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomePage.greetingLocale-11689.test.tsx @@ -73,6 +73,11 @@ vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites vi.mock('../../../hooks/useHomeInbox', () => ({ useHomeInbox: () => ({ pendingApprovalsCount: 0, + // objectui#12602: the approvals preview, and whether each source is + // the caller's to read — the My-work section draws a column per source. + pendingApprovals: [], + pendingApprovalsReadable: true, + notificationsReadable: true, notifications: [], unreadTopicCount: 0, activity: { value: [], status: 'ready', readable: true }, @@ -98,7 +103,7 @@ vi.mock('../../../runtime-config', () => ({ import { HomePage } from '../HomePage'; /* ── The `_drafts` double ───────────────────────────────────────────────────── - * Every render mounts `PendingDraftsBanner`, whose pending-draft count is read + * Every render mounts `usePendingDraftsRow`, whose pending-draft count is read * with the GLOBAL `fetch` from `GET /api/v1/meta/_drafts`. Answered from the * same RECORDING double the sibling files use (copied from * `HomePage.notificationDeepLink.test.tsx`): a known-empty ledger, so no banner diff --git a/packages/app-shell/src/console/home/__tests__/HomePage.inboxLinksTarget.test.tsx b/packages/app-shell/src/console/home/__tests__/HomePage.inboxLinksTarget.test.tsx index 3424305a55..69b67e8f2b 100644 --- a/packages/app-shell/src/console/home/__tests__/HomePage.inboxLinksTarget.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomePage.inboxLinksTarget.test.tsx @@ -100,6 +100,11 @@ vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites vi.mock('../../../hooks/useHomeInbox', () => ({ useHomeInbox: () => ({ pendingApprovalsCount: 0, + // objectui#12602: the approvals preview, and whether each source is + // the caller's to read — the My-work section draws a column per source. + pendingApprovals: [], + pendingApprovalsReadable: true, + notificationsReadable: true, notifications: notificationsFixture, unreadTopicCount: notificationsFixture.length, activity: { value: activitiesFixture, status: 'ready', readable: true }, @@ -132,7 +137,7 @@ vi.mock('../../../runtime-config', () => ({ import { HomePage } from '../HomePage'; /* ── The `_drafts` double (objectui#7307) ───────────────────────────────────── - * Every render of `HomePage` below mounts `PendingDraftsBanner`, which reads the + * Every render of `HomePage` below mounts `usePendingDraftsRow`, which reads the * env-wide pending-draft count through `usePendingDrafts({})`. That hook fetches * `GET /api/v1/meta/_drafts` with the GLOBAL `fetch` — `usePendingDrafts.ts:48`, * no `apiFetch` seam anywhere on the path — from its mount effect @@ -153,7 +158,7 @@ import { HomePage } from '../HomePage'; * ledger, in the `{ drafts: [...] }` envelope `fetchPendingDrafts` reads (the one * `MetadataClient.listDrafts` pins for this endpoint; the bare-array and * `{ data: { drafts } }` shapes parse to the same rows). Empty rather than seeded - * is load-bearing: `PendingDraftsBanner` renders `null` when `(count ?? 0) <= 0`, + * is load-bearing: `usePendingDraftsRow` renders `null` when `(count ?? 0) <= 0`, * and the failing request produced `count === null` — so an empty ledger yields * byte-identical output to what these cases have always rendered, while a seeded * one would add a banner and a `pending-drafts-publish` button to every case's diff --git a/packages/app-shell/src/console/home/__tests__/HomePage.marketplaceDisabled.test.tsx b/packages/app-shell/src/console/home/__tests__/HomePage.marketplaceDisabled.test.tsx index 29ccddc29c..28453ca7f4 100644 --- a/packages/app-shell/src/console/home/__tests__/HomePage.marketplaceDisabled.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomePage.marketplaceDisabled.test.tsx @@ -88,6 +88,11 @@ vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites vi.mock('../../../hooks/useHomeInbox', () => ({ useHomeInbox: () => ({ pendingApprovalsCount: 0, + // objectui#12602: the approvals preview, and whether each source is + // the caller's to read — the My-work section draws a column per source. + pendingApprovals: [], + pendingApprovalsReadable: true, + notificationsReadable: true, notifications: [], unreadTopicCount: 0, activity: { value: [], status: 'ready', readable: true }, @@ -119,7 +124,7 @@ const serverConfig = (cloudUrl: string, marketplace: boolean) => ({ * were wrong with that. * * `HomePage` reaches more than one endpoint. Besides the config read, every - * render mounts `PendingDraftsBanner`, whose `usePendingDrafts({})` fetches + * render mounts `usePendingDraftsRow`, whose `usePendingDrafts({})` fetches * `GET /api/v1/meta/_drafts` with the GLOBAL `fetch` — `usePendingDrafts.ts:48`, * no `apiFetch` seam anywhere on the path — from its mount effect * (`usePendingDrafts.ts:116` via `refresh` at `:90`). The sink handed THAT reader @@ -140,7 +145,7 @@ const serverConfig = (cloudUrl: string, marketplace: boolean) => ({ * * `_drafts` answers a known-EMPTY ledger in the `{ drafts: [...] }` envelope * `fetchPendingDrafts` reads. Empty rather than seeded is load-bearing: - * `PendingDraftsBanner` renders `null` for both `count === null` (what the sink's + * `usePendingDraftsRow` renders `null` for both `count === null` (what the sink's * unparseable answer produced) and `count === 0`, so no assertion in this file * moves. Routes are matched on the PATHNAME because the hook appends a * `?packageId=` scope for package-bound callers; the full url is what is recorded. diff --git a/packages/app-shell/src/console/home/__tests__/HomePage.myWork-12602.test.tsx b/packages/app-shell/src/console/home/__tests__/HomePage.myWork-12602.test.tsx new file mode 100644 index 0000000000..5aa52e7905 --- /dev/null +++ b/packages/app-shell/src/console/home/__tests__/HomePage.myWork-12602.test.tsx @@ -0,0 +1,345 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12602 — Home's "My work" section, section ③ of the artboard + * "系统首页 · 四段式", on the real `HomePage` and the real `HomeActionCenter`. + * + * What these cases pin: + * + * - the approvals waiting on the user are the FIRST column, listed by the + * service's labels, with the count `useHomeInbox` hands the page — the + * count the greeting's line and the bell read (that the bell and the + * column agree over the real shared store is + * `sharedInboxFeed.twoSurfaces.test.tsx`'s case); a row opens that + * request in the Approvals inbox; + * - the section is TWO columns, Approvals then Notifications: no source + * classifies an inbox row as "assigned to me" or carries a due date, so a + * row whose topic reads like an assignment still lands under + * Notifications and nothing reads "overdue" — the card's fork, reported on + * the PR, not guessed from a topic; + * - the pending-drafts row sits under the section's heading and runs the + * publish the retired banner ran, and nothing renders above the greeting + * any more; + * - the section renders nothing for a caller who may read neither source and + * has no draft to publish, and drops the column of a source the caller may + * not read. + * + * `t` echoes the KEY (and the arguments it was handed), so a hardcoded string + * fails here; that every key exists in all ten packs is + * `all-locales-key-parity.test.ts`'s and `check:i18n-keys`'s. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const navigateMock = vi.fn(); +const publishAllMock = vi.fn(async () => ({ ok: true })); + +const state = vi.hoisted(() => ({ + apps: [] as Array>, + pendingApprovals: [] as Array>, + pendingApprovalsCount: 0, + pendingApprovalsReadable: true, + notifications: [] as Array>, + unreadTopicCount: 0, + notificationsReadable: true, + draftCount: 0 as number | null, + draftEntries: [] as Array>, +})); + +vi.mock('react-router-dom', async () => { + const { createElement } = await import('react'); + return { + useNavigate: () => navigateMock, + Link: ({ to, ...rest }: { to: string } & Record) => + createElement('a', { ...rest, href: to }), + }; +}); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, opts?: Record) => + `«${key}»` + + ['count', 'names', 'package'] + .filter((k) => opts?.[k] !== undefined) + .map((k) => `[${k}=${String(opts?.[k])}]`) + .join(''), + language: 'en', + }), + useObjectLabel: () => { + const label = (x: { name?: string; label?: string }) => String(x?.label || x?.name || ''); + return { + appLabel: label, + objectLabel: label, + objectPluralLabel: label, + dashboardLabel: label, + pageLabel: label, + reportLabel: label, + viewLabel: (_object: string, _view: string, fallback?: string) => fallback ?? '', + }; + }, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Dev Admin', email: 'dev.admin@example.com' } }), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => ({ + ...(await importOriginal()), + useAgents: () => ({ agents: [] }), + isAskAgent: () => false, + agentHasCapability: () => false, +})); + +vi.mock('../../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ apps: state.apps, objects: [], dashboards: [], pages: [], reports: [], loading: false }), +})); +vi.mock('../../../context/NavigationContext', () => ({ + useNavigationContext: () => ({ currentAppName: 'crm' }), +})); +vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ useCanAuthorMetadata: () => true })); +// The page's one inbox seam: both sources, their answers and their read grants. +vi.mock('../../../hooks/useHomeInbox', () => ({ + useHomeInbox: () => ({ + pendingApprovals: state.pendingApprovals, + pendingApprovalsCount: state.pendingApprovalsCount, + pendingApprovalsStatus: 'ready', + pendingApprovalsReadable: state.pendingApprovalsReadable, + notifications: state.notifications, + unreadTopicCount: state.unreadTopicCount, + notificationsStatus: 'ready', + notificationsReadable: state.notificationsReadable, + activity: { value: [], status: 'ready', readable: true }, + }), +})); +vi.mock('../../../runtime-config', () => ({ + getRuntimeConfig: () => ({ branding: { productName: 'ObjectStack' } }), + isMarketplaceEnabled: () => true, + isAiStudioEnabled: () => true, +})); +vi.mock('../../../hooks/useRecentItems', () => ({ useRecentItems: () => ({ recentItems: [] }) })); +vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites: [] }) })); +vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' })); +// The drafts row's two seams, the ones the retired banner read. +vi.mock('../../../preview/usePendingDrafts', () => ({ + usePendingDrafts: () => ({ count: state.draftCount, entries: state.draftEntries }), +})); +vi.mock('../../../preview/usePublishAllDrafts', () => ({ + usePublishAllDrafts: () => ({ publishAll: publishAllMock, publishing: false }), +})); + +import { HomePage } from '../HomePage'; + +const CRM = { name: 'crm', label: 'CRM', icon: 'Building2' }; + +/** Every URL the page handed the global `fetch` — none is expected. */ +let fetchCalls: string[] = []; + +beforeEach(() => { + navigateMock.mockReset(); + publishAllMock.mockClear(); + state.apps = [CRM]; + state.pendingApprovals = []; + state.pendingApprovalsCount = 0; + state.pendingApprovalsReadable = true; + state.notifications = []; + state.unreadTopicCount = 0; + state.notificationsReadable = true; + state.draftCount = 0; + state.draftEntries = []; + fetchCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + fetchCalls.push(String(input)); + return { ok: false, status: 404, json: async () => ({}) }; + }), + ); +}); + +afterEach(() => { + expect(fetchCalls).toEqual([]); + cleanup(); + vi.unstubAllGlobals(); +}); + +const TWO_APPROVALS = [ + { id: 'apr_1', processLabel: 'Discount approval', recordTitle: 'QUO-0007', processName: 'discount', createdAt: new Date().toISOString() }, + { id: 'apr_2', processLabel: 'Contract approval', recordTitle: 'Globex renewal', processName: 'contract', createdAt: new Date().toISOString() }, +]; + +/** The section's columns, in the order they are drawn. */ +const columns = () => + Array.from(screen.getByTestId('home-my-work').querySelectorAll('[data-testid^="home-my-work-"][data-testid$="s"]')) + .map((el) => el.getAttribute('data-testid')) + .filter((id) => id === 'home-my-work-approvals' || id === 'home-my-work-notifications'); + +describe('the approvals column (objectui#12602)', () => { + it('is the first column, lists the requests, and carries the count Home reads', () => { + state.pendingApprovals = TWO_APPROVALS; + state.pendingApprovalsCount = 2; + render(); + + expect(columns()[0]).toBe('home-my-work-approvals'); + const approvals = screen.getByTestId('home-my-work-approvals'); + expect(within(approvals).getByTestId('home-my-work-approvals-count')).toHaveTextContent('2'); + // The count beside the greeting is the same number from the same seam. + expect(screen.getByTestId('home-work-summary')).toHaveTextContent('[count=2]'); + const rows = within(approvals).getAllByTestId('home-my-work-approval-row'); + expect(rows.map((r) => r.textContent)).toEqual([ + expect.stringContaining('Discount approval · QUO-0007'), + expect.stringContaining('Contract approval · Globex renewal'), + ]); + }); + + it('opens a request in the Approvals inbox of the app the user is in', async () => { + state.pendingApprovals = TWO_APPROVALS; + state.pendingApprovalsCount = 2; + render(); + + await userEvent.setup().click(screen.getAllByTestId('home-my-work-approval-row')[1]); + expect(navigateMock).toHaveBeenCalledWith('/apps/crm/system/approvals?request=apr_2'); + }); + + it('says nothing is waiting at zero — on the approvals column only', () => { + render(); + expect(screen.getByTestId('home-my-work-approvals-notice')).toHaveTextContent('«home.myWork.approvalsEmpty»'); + expect(screen.queryByTestId('home-my-work-approvals-count')).toBeNull(); + }); +}); + +describe('two columns until an inbox row can say what kind it is (objectui#12602)', () => { + it('draws Approvals then Notifications, and no third column', () => { + render(); + expect(columns()).toEqual(['home-my-work-approvals', 'home-my-work-notifications']); + }); + + it('files a row whose topic reads like an assignment under Notifications, with no "overdue"', () => { + // The kind is not on the record: guessing it from `topic` or the title is + // exactly what the card rules out. + state.notifications = [ + { id: 'n1', title: 'Lead assigned: Acme Corp', actionUrl: '/lead/l_1', createdAt: '2020-01-01T00:00:00Z' }, + ]; + state.unreadTopicCount = 1; + render(); + + const notifications = screen.getByTestId('home-my-work-notifications'); + expect(within(notifications).getByText('Lead assigned: Acme Corp')).toBeInTheDocument(); + expect(within(screen.getByTestId('home-my-work-approvals')).queryByText('Lead assigned: Acme Corp')).toBeNull(); + expect(screen.getByTestId('home-my-work').textContent).not.toMatch(/overdue|逾期/i); + }); + + it('totals the two columns in the heading pill — the bell badge’s two addends', () => { + state.pendingApprovals = TWO_APPROVALS; + state.pendingApprovalsCount = 2; + state.notifications = [{ id: 'n1', title: 'Weekly digest', createdAt: new Date().toISOString() }]; + state.unreadTopicCount = 9; + render(); + expect(screen.getByTestId('home-my-work-count')).toHaveTextContent('11'); + expect(screen.getByTestId('home-my-work-notifications-count')).toHaveTextContent('9'); + }); + + it('opens the inbox the bell opens, from the heading and from the column', async () => { + render(); + const user = userEvent.setup(); + await user.click(screen.getByTestId('home-my-work-open-inbox')); + await user.click(screen.getByTestId('home-my-work-notifications-view-all')); + expect(navigateMock.mock.calls.map((c) => c[0])).toEqual([ + '/apps/crm/sys_inbox_message?view=mine', + '/apps/crm/sys_inbox_message?view=mine', + ]); + }); +}); + +describe('the pending-drafts row (objectui#12602)', () => { + const DRAFTS = [ + { type: 'object', name: 'ticket', label: 'Ticket', packageId: 'studio_test' }, + { type: 'app', name: 'ticket_desk', label: 'Ticket Desk', packageId: 'studio_test' }, + { type: 'page', name: 'help_center', packageId: 'studio_test' }, + { type: 'view', name: 'all_tickets', packageId: 'studio_test' }, + ]; + + it('sits under the My-work heading and runs the publish the banner ran', async () => { + state.draftCount = 4; + state.draftEntries = DRAFTS; + render(); + + const row = within(screen.getByTestId('home-my-work')).getByTestId('home-pending-drafts'); + expect(row).toHaveTextContent('«home.pendingDrafts.title»[count=4]'); + // The first three names — label first, the name where none was served — + // in the package they share. + expect(row).toHaveTextContent('[names=Ticket«home.pendingDrafts.nameSeparator»Ticket Desk«home.pendingDrafts.nameSeparator»help_center][package=studio_test]'); + + await userEvent.setup().click(within(row).getByTestId('pending-drafts-publish')); + expect(publishAllMock).toHaveBeenCalledTimes(1); + }); + + it('retires the banner: nothing renders above the greeting', () => { + state.draftCount = 4; + state.draftEntries = DRAFTS; + render(); + + const greeting = screen.getByRole('heading', { level: 1 }); + const row = screen.getByTestId('home-pending-drafts'); + expect(greeting.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getAllByTestId('pending-drafts-publish')).toHaveLength(1); + expect(screen.queryByText(/home\.pendingDrafts\.message/)).toBeNull(); + }); + + it('names no package when the drafts are spread over several', () => { + state.draftCount = 2; + state.draftEntries = [ + { type: 'object', name: 'ticket', packageId: 'a' }, + { type: 'object', name: 'invoice', packageId: 'b' }, + ]; + render(); + expect(screen.getByTestId('home-pending-drafts')).toHaveTextContent('«home.pendingDrafts.waiting»'); + expect(screen.getByTestId('home-pending-drafts').textContent).not.toContain('[package='); + }); + + it('is still offered with no app yet — the moment the assistant drafted the first one', async () => { + state.apps = []; + state.draftCount = 1; + state.draftEntries = [{ type: 'app', name: 'helpdesk', packageId: 'p' }]; + render(); + await userEvent.setup().click(screen.getByTestId('pending-drafts-publish')); + expect(publishAllMock).toHaveBeenCalledTimes(1); + }); + + it('is absent with nothing to publish', () => { + render(); + expect(screen.queryByTestId('home-pending-drafts')).toBeNull(); + }); +}); + +describe('who sees the section (objectui#12602)', () => { + it('renders nothing for a caller who may read neither source and has no draft', () => { + state.pendingApprovalsReadable = false; + state.notificationsReadable = false; + render(); + expect(screen.queryByTestId('home-my-work')).toBeNull(); + expect(screen.queryByText('«home.myWork.title»')).toBeNull(); + }); + + it('drops the column, and the inbox link, of a source the caller may not read', () => { + state.notificationsReadable = false; + render(); + expect(columns()).toEqual(['home-my-work-approvals']); + expect(screen.queryByTestId('home-my-work-open-inbox')).toBeNull(); + }); + + it('keeps the drafts row for a caller with drafts and no readable source', () => { + state.pendingApprovalsReadable = false; + state.notificationsReadable = false; + state.draftCount = 1; + state.draftEntries = [{ type: 'object', name: 'ticket', packageId: 'p' }]; + render(); + expect(within(screen.getByTestId('home-my-work')).getByTestId('home-pending-drafts')).toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/console/home/__tests__/HomePage.notificationDeepLink.test.tsx b/packages/app-shell/src/console/home/__tests__/HomePage.notificationDeepLink.test.tsx index fb50eab215..82ea997d2e 100644 --- a/packages/app-shell/src/console/home/__tests__/HomePage.notificationDeepLink.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomePage.notificationDeepLink.test.tsx @@ -96,6 +96,11 @@ vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites vi.mock('../../../hooks/useHomeInbox', () => ({ useHomeInbox: () => ({ pendingApprovalsCount: 0, + // objectui#12602: the approvals preview, and whether each source is + // the caller's to read — the My-work section draws a column per source. + pendingApprovals: [], + pendingApprovalsReadable: true, + notificationsReadable: true, notifications: notificationsFixture, unreadTopicCount: notificationsFixture.length, activity: { value: [], status: 'ready', readable: true }, @@ -128,7 +133,7 @@ vi.mock('../../../runtime-config', () => ({ import { HomePage } from '../HomePage'; /* ── The `_drafts` double (objectui#7307) ───────────────────────────────────── - * Every render of `HomePage` below mounts `PendingDraftsBanner`, which reads the + * Every render of `HomePage` below mounts `usePendingDraftsRow`, which reads the * env-wide pending-draft count through `usePendingDrafts({})`. That hook fetches * `GET /api/v1/meta/_drafts` with the GLOBAL `fetch` — `usePendingDrafts.ts:48`, * no `apiFetch` seam anywhere on the path — from its mount effect @@ -149,7 +154,7 @@ import { HomePage } from '../HomePage'; * ledger, in the `{ drafts: [...] }` envelope `fetchPendingDrafts` reads (the one * `MetadataClient.listDrafts` pins for this endpoint; the bare-array and * `{ data: { drafts } }` shapes parse to the same rows). Empty rather than seeded - * is load-bearing: `PendingDraftsBanner` renders `null` when `(count ?? 0) <= 0`, + * is load-bearing: `usePendingDraftsRow` renders `null` when `(count ?? 0) <= 0`, * and the failing request produced `count === null` — so an empty ledger yields * byte-identical output to what these cases have always rendered, while a seeded * one would add a banner and a `pending-drafts-publish` button to every case's diff --git a/packages/app-shell/src/console/home/__tests__/HomeRail.displayLocale-10668.test.tsx b/packages/app-shell/src/console/home/__tests__/HomeRail.displayLocale-10668.test.tsx index 6538f349c2..c9f30a9f2d 100644 --- a/packages/app-shell/src/console/home/__tests__/HomeRail.displayLocale-10668.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomeRail.displayLocale-10668.test.tsx @@ -38,11 +38,17 @@ function railUnder(locale: string) {
    {}} + onOpenApproval={() => {}} + onOpenInbox={() => {}} onOpenNotification={() => {}} t={t} /> diff --git a/packages/app-shell/src/console/home/__tests__/HomeRail.i18n.test.tsx b/packages/app-shell/src/console/home/__tests__/HomeRail.i18n.test.tsx index 6e446a1927..f6421a1750 100644 --- a/packages/app-shell/src/console/home/__tests__/HomeRail.i18n.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomeRail.i18n.test.tsx @@ -1,9 +1,10 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * The Home rail must not leak English into a localized console: the action - * centre heading comes from `home.actionCenter.*` (which had no entry in any - * locale file, so every language fell back to the inline English default) and + * The Home rail must not leak English into a localized console: the My-work + * heading comes from `home.myWork.*` (objectui#12602; the action centre it + * replaced read `home.actionCenter.*`, which once had no entry in any locale + * file, so every language fell back to the inline English default) and * notification timestamps go through Intl.RelativeTimeFormat instead of the * old hand-rolled `${n}d` suffix. */ @@ -17,8 +18,8 @@ import { HomeActionCenter } from '../HomeRail'; const zh = (key: string, opts?: any) => ({ - 'home.actionCenter.title': '待办事项', - 'home.actionCenter.empty': '全部处理完毕', + 'home.myWork.title': '我的工作', + 'home.myWork.notifications': '通知', })[key] ?? opts?.defaultValue ?? key; function threeDaysAgo() { @@ -29,13 +30,19 @@ function renderRail() { return render( {}} + onOpenApproval={() => {}} + onOpenInbox={() => {}} onOpenNotification={() => {}} t={zh} /> @@ -46,8 +53,8 @@ function renderRail() { describe('HomeActionCenter localization', () => { it('renders the translated heading rather than the English default', () => { renderRail(); - expect(screen.getByText('待办事项')).toBeTruthy(); - expect(screen.queryByText('Needs your attention')).toBeNull(); + expect(screen.getByText('我的工作')).toBeTruthy(); + expect(screen.queryByText('My work')).toBeNull(); }); it('formats the notification timestamp for the active locale', () => { diff --git a/packages/app-shell/src/hooks/__tests__/sharedInboxFeed.twoSurfaces.test.tsx b/packages/app-shell/src/hooks/__tests__/sharedInboxFeed.twoSurfaces.test.tsx index c93618ef9e..b9a9f3d571 100644 --- a/packages/app-shell/src/hooks/__tests__/sharedInboxFeed.twoSurfaces.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/sharedInboxFeed.twoSurfaces.test.tsx @@ -291,16 +291,21 @@ import { __resetSharedUserFeeds } from '../sharedUserFeeds'; * works, so every assertion has to say WHICH panel it is talking about. */ function HomeProbe() { - const { pendingApprovalsCount, notifications, notificationsStatus, unreadTopicCount } = - useHomeInbox(); + const inbox = useHomeInbox(); return (
    {}} + onOpenApproval={() => {}} + onOpenInbox={() => {}} onOpenNotification={() => {}} t={(key: string, options?: any) => String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) => @@ -323,12 +328,13 @@ function HomeSurfaces() { } const home = () => screen.getByTestId('home-cards'); -/** Home's "Needs your attention" badge, or null when the card shows none. */ -const homeBadge = (): string | null => { - const heading = within(home()).getByText('Needs your attention'); - const badge = heading.parentElement?.querySelector('span.tabular-nums'); - return badge ? badge.textContent : null; -}; +/** Home's "My work" total pill (objectui#12602), or null when it shows none. */ +const homeBadge = (): string | null => + within(home()).queryByTestId('home-my-work-count')?.textContent ?? null; +/** Home's notifications column says its read is unanswered (loading or failed). */ +const homeUnanswered = () => + within(home()).queryByTestId('home-my-work-notifications-notice')?.getAttribute('data-status') !== 'ready' && + within(home()).queryByTestId('home-my-work-notifications-notice') !== null; /** Matches inside the bell only — i.e. anywhere that is not Home's card. */ const inBell = (title: string) => screen.queryAllByText(title).filter((el) => !home().contains(el)); @@ -386,7 +392,7 @@ describe('#4316 — an already-read message is not "needs your attention"', () = await waitFor(() => expect(inboxReads().length).toBeGreaterThan(0)); await waitFor(() => - expect(within(home()).getByText("You're all caught up")).toBeInTheDocument(), + expect(within(home()).getByText('No unread notifications')).toBeInTheDocument(), ); // Home: no rows, no badge. expect(within(home()).queryAllByText(/Approval request \d+ needs your decision/)).toHaveLength(0); @@ -424,8 +430,9 @@ describe('#4316 — an already-read message is not "needs your attention"', () = render(); - // Home lists the unread titles … - for (const title of UNREAD_TITLES) { + // Home lists the unread titles — the newest three, its column's preview + // (objectui#12602) … + for (const title of UNREAD_TITLES.slice(0, 3)) { await waitFor(() => expect(inHome(title).length).toBeGreaterThan(0)); } // … and not one read title, on either surface's "waiting" reading. The @@ -459,11 +466,11 @@ describe('#4316 — an already-read message is not "needs your attention"', () = render(); - // Nine unread, and both surfaces say nine. Home's LIST is still capped at - // `limit` (5) — the cap is a presentation slice, and #4329 is the pin that - // it may not leak into the badge. + // Nine unread, and both surfaces say nine. Home's LIST is still capped — + // three rows in the My-work column (objectui#12602) — the cap is a + // presentation slice, and #4329 is the pin that it may not leak into the badge. await waitFor(() => expect(homeBadge()).toBe('9')); - expect(within(home()).queryAllByText(/Approval request \d+ needs your decision/)).toHaveLength(5); + expect(within(home()).queryAllByText(/Approval request \d+ needs your decision/)).toHaveLength(3); expect(screen.getByTestId('inbox-bell-badge')).toHaveTextContent('9'); }); }); @@ -502,10 +509,24 @@ describe('#4329 — the badge is the total, the list is the preview', () => { await waitFor(() => expect(screen.getByTestId('inbox-bell-badge')).toHaveTextContent('6')); expect(homeBadge()).toBe('6'); - // …and the approvals row is still listed with its own count, unchanged. - expect(within(home()).getByTestId('home-action-approvals')).toHaveTextContent( - '2 pending approvals', - ); + // …and the approvals column carries its own count, unchanged. + expect(within(home()).getByTestId('home-my-work-approvals-count')).toHaveTextContent('2'); + }); + + it('the approvals column carries the count the bell badges, from the same read (objectui#12602)', async () => { + // The My-work section's first column lists the requests themselves; its + // pill is their number, which is the bell's approvals addend by + // construction — one request feeds both, and nothing else counts them. + inboxRows = []; + receiptRows = []; + approvalsPending(['ar_1', 'ar_2', 'ar_3']); + + render(); + + await waitFor(() => expect(screen.getByTestId('inbox-bell-badge')).toHaveTextContent('3')); + expect(within(home()).getByTestId('home-my-work-approvals-count')).toHaveTextContent('3'); + expect(within(home()).getAllByTestId('home-my-work-approval-row')).toHaveLength(3); + expect(homeBadge()).toBe('3'); }); it('reports the same count past the bell’s "9+" display clamp', async () => { @@ -523,8 +544,9 @@ describe('#4329 — the badge is the total, the list is the preview', () => { await waitFor(() => expect(homeBadge()).toBe('12')); expect(screen.getByTestId('inbox-bell-badge')).toHaveTextContent('9+'); - // The list is unmoved by any of it — still five rows. - expect(within(home()).queryAllByText(/Waiting item \d+/)).toHaveLength(5); + // The list is unmoved by any of it — the column's three rows + // (objectui#12602; five in the action centre it replaced). + expect(within(home()).queryAllByText(/Waiting item \d+/)).toHaveLength(3); }); }); @@ -600,7 +622,7 @@ describe('#4225 — a failed inbox read reaches Home as an error, not as stale d receiptRows = receiptsFor(MIXED, MIXED_READ_IDS); render(); await waitFor(() => expect(inHome('Approval reminder: INV-1008').length).toBeGreaterThan(0)); - expect(within(home()).queryByTestId('home-action-unanswered')).not.toBeInTheDocument(); + expect(homeUnanswered()).toBe(false); // Re-read the same feed, in the one way a mounted page really does it: the // tab regains focus. (A remount would NOT do — it lands inside the store's @@ -608,11 +630,9 @@ describe('#4225 — a failed inbox read reaches Home as an error, not as stale d inboxBehaviour = denied; document.dispatchEvent(new Event('visibilitychange')); - await waitFor(() => - expect(within(home()).getByTestId('home-action-unanswered')).toBeInTheDocument(), - ); + await waitFor(() => expect(homeUnanswered()).toBe(true)); expect(within(home()).getByText('An unexpected error occurred.')).toBeInTheDocument(); - expect(within(home()).queryByText("You're all caught up")).not.toBeInTheDocument(); + expect(within(home()).queryByText('No unread notifications')).not.toBeInTheDocument(); // …and the rows are still on screen. "Not an answer any more" is the claim, // not "throw the user's inbox away" — the two are different states and the // status is what separates them. @@ -637,9 +657,9 @@ describe('#4225 — a failed inbox read reaches Home as an error, not as stale d render(); await waitFor(() => - expect(within(home()).getByText("You're all caught up")).toBeInTheDocument(), + expect(within(home()).getByText('No unread notifications')).toBeInTheDocument(), ); - expect(within(home()).queryByTestId('home-action-unanswered')).not.toBeInTheDocument(); + expect(homeUnanswered()).toBe(false); expect(screen.queryByTestId('inbox-bell-badge')).not.toBeInTheDocument(); }); }); diff --git a/packages/app-shell/src/hooks/__tests__/useHomeInbox.myWork-12602.test.tsx b/packages/app-shell/src/hooks/__tests__/useHomeInbox.myWork-12602.test.tsx new file mode 100644 index 0000000000..945883ada8 --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/useHomeInbox.myWork-12602.test.tsx @@ -0,0 +1,123 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12602 — what `useHomeInbox` hands Home's "My work" section beyond + * the counts, over the real shared store and a stubbed `fetch`: + * + * - the approval requests themselves, from the ONE approvals read the bell + * badges: one entry per distinct request id, so the count stays the + * list's length, and the service's display labels copied as served; + * - whether each source is the caller's to read. The inbox follows the + * predicate the activity feed applies to `sys_activity` (objectui#12081): + * `false` only when the LOADED permissions refuse read on + * `sys_inbox_message`, unknown permissions read `true`. The approvals + * endpoint answers every signed-in user about their own requests, so it is + * `false` only with nobody signed in. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +const auth = vi.hoisted(() => ({ user: { id: 'u1', email: 'u1@example.com' } as { id: string; email: string } | null })); +const perms = vi.hoisted(() => ({ isLoaded: false, refused: new Set() })); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: auth.user }), +})); +vi.mock('@object-ui/permissions', async (importOriginal) => ({ + ...(await importOriginal>()), + usePermissions: () => ({ + isLoaded: perms.isLoaded, + can: (object: string) => !perms.refused.has(object), + }), +})); +vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => null })); +vi.mock('../../utils/authToken', () => ({ bearerAuthHeaders: () => ({}) })); + +import { useHomeInbox } from '../useHomeInbox'; +import { __resetSharedUserFeeds } from '../sharedUserFeeds'; + +let rows: Array> = []; + +const settle = async () => { + await act(async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); +}; + +beforeEach(() => { + auth.user = { id: 'u1', email: 'u1@example.com' }; + perms.isLoaded = false; + perms.refused = new Set(); + rows = []; + __resetSharedUserFeeds(); + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => + Promise.resolve( + String(url).includes('/api/v1/approvals/requests') + ? new Response(JSON.stringify({ data: rows }), { status: 200 }) + : new Response('{}', { status: 404 }), + ), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + __resetSharedUserFeeds(); +}); + +describe('the approval requests behind the count (objectui#12602)', () => { + it('lists the requests, one per id, and counts exactly those', async () => { + rows = [ + { id: 'a1', process_name: 'discount', process_label: 'Discount approval', record_title: 'QUO-0007', created_at: '2026-10-16T01:00:00Z' }, + { id: 'a1', process_name: 'discount', process_label: 'Discount approval', record_title: 'QUO-0007' }, + { id: 'a2', process_name: 'contract' }, + ]; + const { result } = renderHook(() => useHomeInbox()); + await settle(); + expect(result.current.pendingApprovalsStatus).toBe('ready'); + expect(result.current.pendingApprovalsCount).toBe(2); + expect(result.current.pendingApprovals).toEqual([ + { id: 'a1', processName: 'discount', processLabel: 'Discount approval', recordTitle: 'QUO-0007', createdAt: '2026-10-16T01:00:00Z' }, + { id: 'a2', processName: 'contract', processLabel: undefined, recordTitle: undefined, createdAt: undefined }, + ]); + }); + + it('caps the preview at the limit while the count stays the whole list', async () => { + rows = Array.from({ length: 7 }, (_, i) => ({ id: `a${i}`, process_name: 'p' })); + const { result } = renderHook(() => useHomeInbox(3)); + await settle(); + expect(result.current.pendingApprovalsCount).toBe(7); + expect(result.current.pendingApprovals.map((r) => r.id)).toEqual(['a0', 'a1', 'a2']); + }); +}); + +describe('which sources the caller may read (objectui#12602)', () => { + it('reads both while permissions are not loaded — unknown is not a refusal', () => { + const { result } = renderHook(() => useHomeInbox()); + expect(result.current.notificationsReadable).toBe(true); + expect(result.current.pendingApprovalsReadable).toBe(true); + }); + + it('refuses the inbox when the loaded permissions refuse sys_inbox_message', () => { + perms.isLoaded = true; + perms.refused = new Set(['sys_inbox_message']); + const { result } = renderHook(() => useHomeInbox()); + expect(result.current.notificationsReadable).toBe(false); + expect(result.current.pendingApprovalsReadable).toBe(true); + }); + + it('CONTROL: a loaded grant reads the inbox', () => { + perms.isLoaded = true; + const { result } = renderHook(() => useHomeInbox()); + expect(result.current.notificationsReadable).toBe(true); + }); + + it('has no approvals inbox to read with nobody signed in', () => { + auth.user = null; + const { result } = renderHook(() => useHomeInbox()); + expect(result.current.pendingApprovalsReadable).toBe(false); + }); +}); diff --git a/packages/app-shell/src/hooks/sharedUserFeeds.ts b/packages/app-shell/src/hooks/sharedUserFeeds.ts index 8e60f0b0bd..963d725049 100644 --- a/packages/app-shell/src/hooks/sharedUserFeeds.ts +++ b/packages/app-shell/src/hooks/sharedUserFeeds.ts @@ -520,7 +520,38 @@ function useSharedFeed( // ── Pending approvals ──────────────────────────────────────────────────────── -const approvalsFeed = new SharedFeed(0, APPROVALS_POLL_MS); +/** + * One approval request waiting on the signed-in user, as Home's approvals + * column lists it (objectui#12602): the row the count is the size of. + * + * The display fields are the service's own enrichment + * (`process_label` / `record_title`, the contract's `ApprovalRequest`), copied + * as served. Package-internal, like the feed. + */ +export interface PendingApprovalRequest { + id: string; + /** The flow's human label, when the service resolved one. */ + processLabel?: string; + /** The target record's display name, when the service resolved one. */ + recordTitle?: string; + /** The flow's machine name, as served. */ + processName?: string; + createdAt?: string; +} + +const NO_APPROVALS: PendingApprovalRequest[] = []; + +/** + * The approval requests themselves, not only how many (objectui#12602): Home's + * approvals column lists the first of them, and the count every surface shows + * is this list's length — one request, one answer, so the column and the bell + * cannot disagree about how many are waiting. + */ +const approvalsFeed = new SharedFeed(NO_APPROVALS, APPROVALS_POLL_MS); + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.trim() ? v : undefined; +} /** * The identities the endpoint matches a pending approver against: the user id, @@ -595,6 +626,19 @@ export function useSharedPendingApprovalsCount(): number { * Package-internal: not exported from `./index.ts` or the package entry. */ export function useSharedPendingApprovals(): SharedFeedSnapshot { + const { value, status } = useSharedPendingApprovalRequests(); + return { value: value.length, status }; +} + +/** + * The pending approval requests themselves, with the feed's status — the rows + * {@link useSharedPendingApprovals} counts (objectui#12602). Same feed, same + * one request: Home's approvals column lists these, and its count is their + * number, which is the bell's. + * + * Package-internal: not exported from `./index.ts` or the package entry. + */ +export function useSharedPendingApprovalRequests(): SharedFeedSnapshot { const { user } = useAuth(); const identities = approverIdentities(user); // `user?.id` is the sign-in gate; identities is what the query needs. @@ -617,9 +661,23 @@ export function useSharedPendingApprovals(): SharedFeedSnapshot { return undefined; } const payload = await res.json().catch(() => null); + // One entry per request id: the count has always been the DISTINCT ids, + // and the list is what that count is the size of. const seen = new Set(); - for (const row of (payload?.data || []) as { id: string }[]) seen.add(row.id); - return seen.size; + const requests: PendingApprovalRequest[] = []; + for (const row of (payload?.data || []) as Record[]) { + const id = String(row.id); + if (seen.has(id)) continue; + seen.add(id); + requests.push({ + id, + processLabel: str(row.process_label), + recordTitle: str(row.record_title), + processName: str(row.process_name), + createdAt: str(row.created_at), + }); + } + return requests; }); } diff --git a/packages/app-shell/src/hooks/useHomeInbox.ts b/packages/app-shell/src/hooks/useHomeInbox.ts index 886fb5e7e2..34ee4e4bb7 100644 --- a/packages/app-shell/src/hooks/useHomeInbox.ts +++ b/packages/app-shell/src/hooks/useHomeInbox.ts @@ -58,11 +58,14 @@ * @module */ import { useMemo } from 'react'; +import { useAuth } from '@object-ui/auth'; +import { usePermissions } from '@object-ui/permissions'; import { useHumanActivityFeed, useSharedInboxFeed, - useSharedPendingApprovals, + useSharedPendingApprovalRequests, type ActivityFeedReading, + type PendingApprovalRequest, type SharedFeedStatus, } from './sharedUserFeeds.js'; import { groupNotifications } from '../layout/inboxGrouping.js'; @@ -93,8 +96,26 @@ export interface HomeNotification { */ export type HomeInboxStatus = 'idle' | 'loading' | 'ready' | 'error'; +/** The inbox object the notifications column reads, through the shared feed. */ +const INBOX_OBJECT = 'sys_inbox_message'; + +export type { PendingApprovalRequest }; + export interface HomeInboxData { pendingApprovalsCount: number; + /** + * The first `limit` approval requests waiting on the user, newest first as + * the endpoint serves them (objectui#12602) — the PREVIEW of + * `pendingApprovalsCount`, which is the whole list's length. + */ + pendingApprovals: PendingApprovalRequest[]; + /** + * Whether this caller has an approvals inbox to read at all + * (objectui#12602): the endpoint answers every signed-in user about their + * own requests, so it is `false` only when nobody is signed in — the feed + * then asks nothing. + */ + pendingApprovalsReadable: boolean; /** * Whether `pendingApprovalsCount` is an answer (objectui#12601) — the * approvals feed's own status, passed through as `notificationsStatus` is @@ -122,6 +143,14 @@ export interface HomeInboxData { unreadTopicCount: number; /** Whether `notifications` is an answer — see {@link HomeInboxStatus}. */ notificationsStatus: HomeInboxStatus; + /** + * Whether this caller may read the inbox at all (objectui#12602): `false` + * only when the LOADED permissions refuse read on `sys_inbox_message`, the + * predicate the activity feed applies to `sys_activity` (objectui#12081). + * Unknown permissions read `true`. A surface renders no notifications widget + * for `false` — no empty claim, and no link into a list that answers `403`. + */ + notificationsReadable: boolean; /** * Home's cut of the activity feed, with its status and whether the caller * may read the feed at all (objectui#12081). The rows and the status travel @@ -138,7 +167,14 @@ export function useHomeInbox(limit = 5): HomeInboxData { // activity rows: real human actions only, dropping the sys_*/ai_* churn // (actor "System"). `useSharedInboxFeed` is the bell's already-joined inbox // superset, cut here to what is actually waiting on the user. - const { value: pendingApprovalsCount, status: pendingApprovalsStatus } = useSharedPendingApprovals(); + const { value: approvalRequests, status: pendingApprovalsStatus } = useSharedPendingApprovalRequests(); + // The count is the list's length — the number the bell badges, from the + // same one read (objectui#12602). + const pendingApprovalsCount = approvalRequests.length; + const pendingApprovals = useMemo(() => approvalRequests.slice(0, limit), [approvalRequests, limit]); + const { user } = useAuth(); + const perms = usePermissions(); + const notificationsReadable = !perms.isLoaded || perms.can(INBOX_OBJECT, 'read'); const activity = useHumanActivityFeed(limit); const { value: messages, status: notificationsStatus } = useSharedInboxFeed(); @@ -177,10 +213,13 @@ export function useHomeInbox(limit = 5): HomeInboxData { return { pendingApprovalsCount, + pendingApprovals, + pendingApprovalsReadable: Boolean(user?.id), pendingApprovalsStatus, notifications, unreadTopicCount, notificationsStatus, + notificationsReadable, activity, }; } diff --git a/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx b/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx index 13a7a7be74..c218502c24 100644 --- a/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx +++ b/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx @@ -506,16 +506,17 @@ describe('objectui#3546 slice six — the perm and home namespaces', () => { }); it('the two counting home strings follow their own family on the (s) marker', () => { - // `home.pendingDrafts.message` is the same family, the same banner and the + // `home.pendingDrafts.title` (the drafts row's count since objectui#12602, + // `home.pendingDrafts.message` before it) is the same family, the same row and the // same `{{count}} … (s)` shape, so each pack's answer there is the answer // here: de/fr/es/pt keep a parenthesised marker, zh/ja/ko use a counter, and // ru/ar restructure. Copying `en`'s "(s)" into ru is the mistake this pins. - expect(at(builtInLocales.en, 'home.pendingDrafts.message')).toContain('change(s)'); + expect(at(builtInLocales.en, 'home.pendingDrafts.title')).toContain('change(s)'); const MARKER = /\([\p{L}]{1,4}\)/u; for (const lang of ['de', 'fr', 'es', 'pt'] as const) { expect( - MARKER.test(at(builtInLocales[lang], 'home.pendingDrafts.message') as string), - `${lang} message lost its marker`, + MARKER.test(at(builtInLocales[lang], 'home.pendingDrafts.title') as string), + `${lang} title lost its marker`, ).toBe(true); expect( MARKER.test(at(builtInLocales[lang], 'home.pendingDrafts.publishedVerified') as string), @@ -547,7 +548,7 @@ describe('objectui#3546 slice six — the perm and home namespaces', () => { ); expect(at(builtInLocales[lang], 'perm.facet.designInStudioHint')).not.toContain('→'); // The em dash in `Published & verified — …` is U+2014 everywhere, except zh - // which writes the doubled `——` its own pack uses (home.pendingDrafts.message). + // which writes the doubled `——` its own pack used in the retired home.pendingDrafts.message. const verified = at(builtInLocales[lang], 'home.pendingDrafts.publishedVerified') as string; expect(verified.includes(lang === 'zh' ? '——' : '—'), `${lang} publishedVerified dash`).toBe(true); expect(verified.includes('&'), `${lang} kept en's ampersand`).toBe(lang === 'en'); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index a116c251dd..10e4c6ecbf 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2855,13 +2855,19 @@ const ar = { dismiss: "تجاهل", }, pendingDrafts: { - message: "لديك {{count}} من التغييرات غير المنشورة — انشرها لتصبح فعّالة.", - message_zero: "لديك {{count}} من التغييرات غير المنشورة — انشرها لتصبح فعّالة.", - message_one: "لديك {{count}} من التغييرات غير المنشورة — انشرها لتصبح فعّالة.", - message_two: "لديك {{count}} من التغييرات غير المنشورة — انشرها لتصبح فعّالة.", - message_few: "لديك {{count}} من التغييرات غير المنشورة — انشرها لتصبح فعّالة.", - message_many: "لديك {{count}} من التغييرات غير المنشورة — انشرها لتصبح فعّالة.", - message_other: "لديك {{count}} من التغييرات غير المنشورة — انشرها لتصبح فعّالة.", + // objectui#12602 — the drafts row in Home's "My work" section: the + // count, then the first draft names joined by `nameSeparator`, in + // their package when they share one. + title: "{{count}} من التغييرات غير المنشورة", + title_zero: "{{count}} من التغييرات غير المنشورة", + title_one: "{{count}} من التغييرات غير المنشورة", + title_two: "{{count}} من التغييرات غير المنشورة", + title_few: "{{count}} من التغييرات غير المنشورة", + title_many: "{{count}} من التغييرات غير المنشورة", + title_other: "{{count}} من التغييرات غير المنشورة", + inPackage: "{{names}} بانتظار النشر في {{package}}", + waiting: "{{names}} بانتظار النشر", + nameSeparator: "، ", cta: "نشر", publishing: "جارٍ النشر…", published: "تم النشر! تغييراتك فعّالة الآن.", @@ -2882,9 +2888,16 @@ const ar = { showMoreApps: "المزيد", showLess: "عرض أقل", continueEmpty: "ستظهر هنا العناصر التي تفتحها.", - actionCenter: { - title: "يتطلب انتباهك", - empty: "لقد أنجزت كل شيء", + // objectui#12602 — Home's "My work" section: its heading, the link to the + // full inbox, each column's title, "view all" link and empty line. + myWork: { + title: "عملي", + openInbox: "فتح البريد الوارد", + viewAll: "عرض الكل", + approvals: "بانتظار الموافقة", + approvalsEmpty: "لا شيء بانتظار موافقتك", + notifications: "الإشعارات", + notificationsEmpty: "لا توجد إشعارات غير مقروءة", }, loading: "جارٍ تحميل مساحة العمل…", welcome: "مرحباً بك في ObjectUI", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index c1d41a9e5e..e221f47386 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2643,16 +2643,22 @@ const de = { dismiss: "Ausblenden", }, pendingDrafts: { - message: "Sie haben {{count}} unveröffentlichte Änderung(en) — veröffentlichen Sie sie, um sie live zu schalten.", - message_one: "Sie haben {{count}} unveröffentlichte Änderung — veröffentlichen Sie sie, um sie live zu schalten.", - message_other: "Sie haben {{count}} unveröffentlichte Änderungen — veröffentlichen Sie sie, um sie live zu schalten.", + // objectui#12602 — the drafts row in Home's "My work" section: the + // count, then the first draft names joined by `nameSeparator`, in + // their package when they share one. + title: "{{count}} unveröffentlichte Änderung(en)", + title_one: "{{count}} unveröffentlichte Änderung", + title_other: "{{count}} unveröffentlichte Änderungen", + inPackage: "{{names}} warten in {{package}} auf Veröffentlichung", + waiting: "{{names}} warten auf Veröffentlichung", + nameSeparator: ", ", cta: "Veröffentlichen", publishing: "Wird veröffentlicht…", published: "Veröffentlicht! Ihre Änderungen sind live.", publishFailed: "Veröffentlichen fehlgeschlagen", // objectui#3546 slice six — seedWarn ist derselbe englische Satz wie // console.ai.seedWarn, also dieselbe Übersetzung. Der "(n)"-Marker folgt - // home.pendingDrafts.message ("Änderung(en)") in dieser Familie. + // home.pendingDrafts.title ("Änderung(en)") in dieser Familie. nothing: "Nichts zu veröffentlichen.", probeWarn: "Veröffentlicht, aber die Überprüfung hat Probleme gefunden.", seedWarn: "Veröffentlicht, aber einige Beispieldaten konnten nicht geladen werden.", @@ -2666,9 +2672,16 @@ const de = { showMoreApps: "Mehr", showLess: "Weniger anzeigen", continueEmpty: "Elemente, die Sie öffnen, erscheinen hier.", - actionCenter: { - title: "Erfordert Ihre Aufmerksamkeit", - empty: "Alles erledigt", + // objectui#12602 — Home's "My work" section: its heading, the link to the + // full inbox, each column's title, "view all" link and empty line. + myWork: { + title: "Meine Arbeit", + openInbox: "Posteingang öffnen", + viewAll: "Alle anzeigen", + approvals: "Zur Genehmigung", + approvalsEmpty: "Nichts wartet auf Ihre Genehmigung", + notifications: "Benachrichtigungen", + notificationsEmpty: "Keine ungelesenen Benachrichtigungen", }, loading: "Arbeitsbereich wird geladen…", welcome: "Willkommen bei ObjectUI", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 708d668279..510eb4e418 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3147,9 +3147,16 @@ const en = { showMoreApps: 'More', showLess: 'Show less', continueEmpty: 'Items you open will show up here.', - actionCenter: { - title: 'Needs your attention', - empty: "You're all caught up", + // objectui#12602 — Home's "My work" section: its heading, the link to the + // full inbox, each column's title, "view all" link and empty line. + myWork: { + title: "My work", + openInbox: "Open inbox", + viewAll: "View all", + approvals: "Awaiting approval", + approvalsEmpty: "Nothing waiting for your approval", + notifications: "Notifications", + notificationsEmpty: "No unread notifications", }, greetingMorning: 'Good morning', greetingAfternoon: 'Good afternoon', @@ -3205,9 +3212,15 @@ const en = { dismiss: 'Dismiss', }, pendingDrafts: { - message: 'You have {{count}} unpublished change(s) — publish to make them live.', - message_one: 'You have {{count}} unpublished change — publish to make it live.', - message_other: 'You have {{count}} unpublished changes — publish to make them live.', + // objectui#12602 — the drafts row in Home's "My work" section: the + // count, then the first draft names joined by `nameSeparator`, in + // their package when they share one. + title: "{{count}} unpublished change(s)", + title_one: "{{count}} unpublished change", + title_other: "{{count}} unpublished changes", + inPackage: "{{names}} waiting to publish in {{package}}", + waiting: "{{names}} waiting to publish", + nameSeparator: ", ", cta: 'Publish', publishing: 'Publishing…', published: 'Published! Your changes are live.', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index a0f59d957d..efe5e766d2 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2695,10 +2695,16 @@ const es = { dismiss: "Descartar", }, pendingDrafts: { - message: "Tiene {{count}} cambio(s) sin publicar — publíquelos para activarlos.", - message_one: "Tiene {{count}} cambio sin publicar — publíquelo para activarlo.", - message_many: "Tiene {{count}} de cambios sin publicar — publíquelos para activarlos.", - message_other: "Tiene {{count}} cambios sin publicar — publíquelos para activarlos.", + // objectui#12602 — the drafts row in Home's "My work" section: the + // count, then the first draft names joined by `nameSeparator`, in + // their package when they share one. + title: "{{count}} cambio(s) sin publicar", + title_one: "{{count}} cambio sin publicar", + title_many: "{{count}} cambios sin publicar", + title_other: "{{count}} cambios sin publicar", + inPackage: "{{names}} pendientes de publicar en {{package}}", + waiting: "{{names}} pendientes de publicar", + nameSeparator: ", ", cta: "Publicar", publishing: "Publicando…", published: "¡Publicado! Sus cambios están activos.", @@ -2719,9 +2725,16 @@ const es = { showMoreApps: "Más", showLess: "Mostrar menos", continueEmpty: "Los elementos que abras aparecerán aquí.", - actionCenter: { - title: "Requiere tu atención", - empty: "Estás al día", + // objectui#12602 — Home's "My work" section: its heading, the link to the + // full inbox, each column's title, "view all" link and empty line. + myWork: { + title: "Mi trabajo", + openInbox: "Abrir bandeja de entrada", + viewAll: "Ver todo", + approvals: "Pendiente de aprobación", + approvalsEmpty: "Nada pendiente de su aprobación", + notifications: "Notificaciones", + notificationsEmpty: "No hay notificaciones sin leer", }, loading: "Cargando espacio de trabajo…", welcome: "Bienvenido a ObjectUI", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 2dc0259dbb..541cf312fc 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2692,10 +2692,16 @@ const fr = { dismiss: "Ignorer", }, pendingDrafts: { - message: "Vous avez {{count}} modification(s) non publiée(s) — publiez-les pour les activer.", - message_one: "Vous avez {{count}} modification non publiée — publiez-la pour l'activer.", - message_many: "Vous avez {{count}} de modifications non publiées — publiez-les pour les activer.", - message_other: "Vous avez {{count}} modifications non publiées — publiez-les pour les activer.", + // objectui#12602 — the drafts row in Home's "My work" section: the + // count, then the first draft names joined by `nameSeparator`, in + // their package when they share one. + title: "{{count}} modification(s) non publiée(s)", + title_one: "{{count}} modification non publiée", + title_many: "{{count}} modifications non publiées", + title_other: "{{count}} modifications non publiées", + inPackage: "{{names}} en attente de publication dans {{package}}", + waiting: "{{names}} en attente de publication", + nameSeparator: ", ", cta: "Publier", publishing: "Publication…", published: "Publié ! Vos modifications sont actives.", @@ -2716,9 +2722,16 @@ const fr = { showMoreApps: "Plus", showLess: "Afficher moins", continueEmpty: "Les éléments que vous ouvrez apparaîtront ici.", - actionCenter: { - title: "Nécessite votre attention", - empty: "Vous êtes à jour", + // objectui#12602 — Home's "My work" section: its heading, the link to the + // full inbox, each column's title, "view all" link and empty line. + myWork: { + title: "Mon travail", + openInbox: "Ouvrir la boîte de réception", + viewAll: "Tout afficher", + approvals: "À approuver", + approvalsEmpty: "Rien n'attend votre approbation", + notifications: "Notifications", + notificationsEmpty: "Aucune notification non lue", }, loading: "Chargement de l'espace de travail…", welcome: "Bienvenue dans ObjectUI", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 209dc8f6d4..fed711a974 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2645,9 +2645,15 @@ const ja = { dismiss: "閉じる", }, pendingDrafts: { - message: "未公開の変更が {{count}} 件あります — 公開すると反映されます。", - message_one: "未公開の変更が {{count}} 件あります — 公開すると反映されます。", - message_other: "未公開の変更が {{count}} 件あります — 公開すると反映されます。", + // objectui#12602 — the drafts row in Home's "My work" section: the + // count, then the first draft names joined by `nameSeparator`, in + // their package when they share one. + title: "未公開の変更 {{count}} 件", + title_one: "未公開の変更 {{count}} 件", + title_other: "未公開の変更 {{count}} 件", + inPackage: "{{names}} が {{package}} で公開待ちです", + waiting: "{{names}} が公開待ちです", + nameSeparator: "、", cta: "公開", publishing: "公開しています…", published: "公開しました。変更が反映されています。", @@ -2668,9 +2674,16 @@ const ja = { showMoreApps: "もっと見る", showLess: "折りたたむ", continueEmpty: "開いた項目がここに表示されます。", - actionCenter: { - title: "対応が必要な項目", - empty: "すべて対応済みです", + // objectui#12602 — Home's "My work" section: its heading, the link to the + // full inbox, each column's title, "view all" link and empty line. + myWork: { + title: "マイワーク", + openInbox: "受信トレイを開く", + viewAll: "すべて表示", + approvals: "承認待ち", + approvalsEmpty: "承認待ちの項目はありません", + notifications: "通知", + notificationsEmpty: "未読の通知はありません", }, loading: "ワークスペースを読み込み中…", welcome: "ObjectUI へようこそ", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 16aac846dd..3c50063634 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2643,9 +2643,15 @@ const ko = { dismiss: "닫기", }, pendingDrafts: { - message: "게시되지 않은 변경이 {{count}}건 있습니다 — 게시하면 반영됩니다.", - message_one: "게시되지 않은 변경이 {{count}}건 있습니다 — 게시하면 반영됩니다.", - message_other: "게시되지 않은 변경이 {{count}}건 있습니다 — 게시하면 반영됩니다.", + // objectui#12602 — the drafts row in Home's "My work" section: the + // count, then the first draft names joined by `nameSeparator`, in + // their package when they share one. + title: "게시되지 않은 변경 {{count}}개", + title_one: "게시되지 않은 변경 {{count}}개", + title_other: "게시되지 않은 변경 {{count}}개", + inPackage: "{{names}} · {{package}}에서 게시 대기 중", + waiting: "{{names}} · 게시 대기 중", + nameSeparator: ", ", cta: "게시", publishing: "게시하는 중…", published: "게시했습니다! 변경 사항이 반영되었습니다.", @@ -2665,9 +2671,16 @@ const ko = { showMoreApps: "더 보기", showLess: "간략히", continueEmpty: "열어본 항목이 여기에 표시됩니다.", - actionCenter: { - title: "확인이 필요한 항목", - empty: "모두 처리했습니다", + // objectui#12602 — Home's "My work" section: its heading, the link to the + // full inbox, each column's title, "view all" link and empty line. + myWork: { + title: "내 작업", + openInbox: "받은 편지함 열기", + viewAll: "모두 보기", + approvals: "승인 대기", + approvalsEmpty: "승인 대기 중인 항목이 없습니다", + notifications: "알림", + notificationsEmpty: "읽지 않은 알림이 없습니다", }, loading: "워크스페이스 로드 중…", welcome: "ObjectUI에 오신 것을 환영합니다", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index e72163eb30..50023fd335 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2690,10 +2690,16 @@ const pt = { dismiss: "Dispensar", }, pendingDrafts: { - message: "Você tem {{count}} alteração(ões) não publicada(s) — publique para ativá-las.", - message_one: "Você tem {{count}} alteração não publicada — publique para ativá-la.", - message_many: "Você tem {{count}} de alterações não publicadas — publique para ativá-las.", - message_other: "Você tem {{count}} alterações não publicadas — publique para ativá-las.", + // objectui#12602 — the drafts row in Home's "My work" section: the + // count, then the first draft names joined by `nameSeparator`, in + // their package when they share one. + title: "{{count}} alteração(ões) não publicada(s)", + title_one: "{{count}} alteração não publicada", + title_many: "{{count}} alterações não publicadas", + title_other: "{{count}} alterações não publicadas", + inPackage: "{{names}} aguardando publicação em {{package}}", + waiting: "{{names}} aguardando publicação", + nameSeparator: ", ", cta: "Publicar", publishing: "Publicando…", published: "Publicado! Suas alterações estão ativas.", @@ -2714,9 +2720,16 @@ const pt = { showMoreApps: "Mais", showLess: "Mostrar menos", continueEmpty: "Os itens que abrir aparecerão aqui.", - actionCenter: { - title: "Requer a sua atenção", - empty: "Está tudo em dia", + // objectui#12602 — Home's "My work" section: its heading, the link to the + // full inbox, each column's title, "view all" link and empty line. + myWork: { + title: "Meu trabalho", + openInbox: "Abrir caixa de entrada", + viewAll: "Ver tudo", + approvals: "Aguardando aprovação", + approvalsEmpty: "Nada aguardando sua aprovação", + notifications: "Notificações", + notificationsEmpty: "Nenhuma notificação não lida", }, loading: "Carregando espaço de trabalho…", welcome: "Bem-vindo ao ObjectUI", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 2f3129d318..b130f411c9 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2759,11 +2759,17 @@ const ru = { dismiss: "Закрыть", }, pendingDrafts: { - message: "У вас {{count}} неопубликованных изменений — опубликуйте их, чтобы они вступили в силу.", - message_one: "У вас {{count}} неопубликованное изменение — опубликуйте его, чтобы оно вступило в силу.", - message_few: "У вас {{count}} неопубликованных изменения — опубликуйте их, чтобы они вступили в силу.", - message_many: "У вас {{count}} неопубликованных изменений — опубликуйте их, чтобы они вступили в силу.", - message_other: "У вас {{count}} неопубликованного изменения — опубликуйте их, чтобы они вступили в силу.", + // objectui#12602 — the drafts row in Home's "My work" section: the + // count, then the first draft names joined by `nameSeparator`, in + // their package when they share one. + title: "{{count}} неопубликованных изменений", + title_one: "{{count}} неопубликованное изменение", + title_few: "{{count}} неопубликованных изменения", + title_many: "{{count}} неопубликованных изменений", + title_other: "{{count}} неопубликованного изменения", + inPackage: "{{names}} ожидают публикации в {{package}}", + waiting: "{{names}} ожидают публикации", + nameSeparator: ", ", cta: "Опубликовать", publishing: "Публикация…", published: "Опубликовано! Изменения вступили в силу.", @@ -2785,9 +2791,16 @@ const ru = { showMoreApps: "Ещё", showLess: "Свернуть", continueEmpty: "Открытые вами элементы появятся здесь.", - actionCenter: { - title: "Требует вашего внимания", - empty: "Всё выполнено", + // objectui#12602 — Home's "My work" section: its heading, the link to the + // full inbox, each column's title, "view all" link and empty line. + myWork: { + title: "Моя работа", + openInbox: "Открыть входящие", + viewAll: "Показать все", + approvals: "Ожидают одобрения", + approvalsEmpty: "Нет заявок на одобрение", + notifications: "Уведомления", + notificationsEmpty: "Нет непрочитанных уведомлений", }, loading: "Загрузка рабочего пространства…", welcome: "Добро пожаловать в ObjectUI", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 84f218c757..0082458ad9 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2709,9 +2709,16 @@ const zh = { showMoreApps: '更多', showLess: '收起', continueEmpty: '你打开过的内容会显示在这里。', - actionCenter: { - title: '待办事项', - empty: '全部处理完毕', + // objectui#12602 — Home's "My work" section: its heading, the link to the + // full inbox, each column's title, "view all" link and empty line. + myWork: { + title: "我的工作", + openInbox: "打开收件箱", + viewAll: "查看全部", + approvals: "待审批", + approvalsEmpty: "没有待审批的事项", + notifications: "通知", + notificationsEmpty: "没有未读通知", }, greetingMorning: '早上好', greetingAfternoon: '下午好', @@ -2753,9 +2760,15 @@ const zh = { dismiss: '关闭', }, pendingDrafts: { - message: '你有 {{count}} 项未发布的更改 —— 点击发布即可生效。', - message_one: '你有 {{count}} 项未发布的更改 —— 点击发布即可生效。', - message_other: '你有 {{count}} 项未发布的更改 —— 点击发布即可生效。', + // objectui#12602 — the drafts row in Home's "My work" section: the + // count, then the first draft names joined by `nameSeparator`, in + // their package when they share one. + title: "{{count}} 项未发布更改", + title_one: "{{count}} 项未发布更改", + title_other: "{{count}} 项未发布更改", + inPackage: "{{names}} 在 {{package}} 包里等待发布", + waiting: "{{names}} 等待发布", + nameSeparator: "、", cta: '发布', publishing: '发布中…', published: '已发布!更改已生效。',