mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge remote-tracking branch 'origin/feature/failure-notifications' into feature/policy-decrypt-retry
# Conflicts: # app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java # frontend/editor/src/core/components/notifications/NotificationBell.test.tsx # frontend/editor/src/core/components/notifications/NotificationBell.tsx
This commit is contained in:
+5
-2
@@ -10,7 +10,10 @@ import lombok.Getter;
|
||||
@Getter
|
||||
public enum FailureActionId {
|
||||
|
||||
/** No kind offers this any more, but rows already {@code ACKNOWLEDGED} must stay closable. */
|
||||
/**
|
||||
* Kept in the vocabulary for as long as any persisted row is {@code ACKNOWLEDGED}: such rows
|
||||
* must stay readable and closable whether or not any kind currently offers this.
|
||||
*/
|
||||
ACKNOWLEDGE(Execution.SERVER, "Acknowledge"),
|
||||
|
||||
DISMISS(Execution.SERVER, "Dismiss"),
|
||||
@@ -21,7 +24,7 @@ public enum FailureActionId {
|
||||
/** Ask the owner for the password, unlock the document in their client, then retry. */
|
||||
DECRYPT_AND_RETRY(Execution.CLIENT, "Decrypt and retry"),
|
||||
|
||||
/** Only the owner's client can resolve the id. */
|
||||
/** Open the document behind the incident, in whichever client can resolve its id. */
|
||||
VIEW_FILE(Execution.CLIENT, "View file"),
|
||||
|
||||
VIEW_IN_PROCESSOR(Execution.CLIENT, "View in processor");
|
||||
|
||||
+2
@@ -30,8 +30,10 @@ import stirling.software.proprietary.failure.FailureActionException;
|
||||
@Tag(name = "Notifications", description = "Things worth telling the caller about")
|
||||
public class NotificationController {
|
||||
|
||||
/** How many notifications one read returns when the caller does not say: one panelful. */
|
||||
private static final int DEFAULT_LIMIT = 20;
|
||||
|
||||
/** The most one read may return however large a limit the caller asks for. */
|
||||
private static final int MAX_LIMIT = 100;
|
||||
|
||||
private final NotificationService notifications;
|
||||
|
||||
@@ -40,6 +40,8 @@ vi.mock("@app/services/notifications", () => ({
|
||||
const h = vi.hoisted(() => ({
|
||||
hasLocalFile: true,
|
||||
retryPayload: { operation: "removePassword" } as unknown,
|
||||
// This build has the notifications API, except in the one test about the build that does not.
|
||||
notificationsAvailable: true,
|
||||
specs: {} as Record<
|
||||
string,
|
||||
{
|
||||
@@ -56,6 +58,10 @@ vi.mock("@app/services/notificationRetry", () => ({
|
||||
loadRetryPayload: () => Promise.resolve(h.retryPayload),
|
||||
}));
|
||||
|
||||
vi.mock("@app/components/notifications/useNotificationsAvailable", () => ({
|
||||
useNotificationsAvailable: () => h.notificationsAvailable,
|
||||
}));
|
||||
|
||||
// Core's own registry is empty, so without this there are no client actions to test.
|
||||
vi.mock("@app/components/notifications/notificationActions", () => ({
|
||||
useNotificationActions: () => h.specs,
|
||||
@@ -150,9 +156,21 @@ describe("NotificationBell", () => {
|
||||
fetchNotifications.mockReset().mockResolvedValue([]);
|
||||
h.hasLocalFile = true;
|
||||
h.retryPayload = { operation: "removePassword" };
|
||||
h.notificationsAvailable = true;
|
||||
h.specs = {};
|
||||
});
|
||||
|
||||
it("mounts nothing at all in a build with no notifications API", async () => {
|
||||
// No bell and, above all, no poll: an OSS build must not sit on a timer collecting 404s.
|
||||
h.notificationsAvailable = false;
|
||||
|
||||
render(<NotificationBell />);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(fetchNotifications).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows no badge when there is nothing to report", async () => {
|
||||
render(<NotificationBell />);
|
||||
|
||||
|
||||
@@ -6,30 +6,17 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, Tooltip } from "@mantine/core";
|
||||
import { Button } from "@app/ui";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { BellIcon } from "@app/components/notifications/BellIcon";
|
||||
import { BellIcon, Button } from "@app/ui";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal";
|
||||
import { useNotifications } from "@app/hooks/useNotifications";
|
||||
import { useNotificationActions } from "@app/components/notifications/notificationActions";
|
||||
import {
|
||||
isResolvableHere,
|
||||
useNotifications,
|
||||
} from "@app/hooks/useNotifications";
|
||||
import {
|
||||
useNotificationActions,
|
||||
type ClientActionRegistry,
|
||||
type ClientActionSpec,
|
||||
type NotificationActionContext,
|
||||
} from "@app/components/notifications/notificationActions";
|
||||
import { promoteActions } from "@app/components/notifications/notificationActionSlots";
|
||||
import type {
|
||||
AppNotification,
|
||||
NotificationActionOffer,
|
||||
} from "@app/services/notifications";
|
||||
import type { NotificationDocumentState } from "@app/hooks/useNotifications";
|
||||
NotificationItem,
|
||||
type PasswordPrompt,
|
||||
} from "@app/components/notifications/NotificationItem";
|
||||
import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
|
||||
import "@app/components/notifications/NotificationBell.css";
|
||||
|
||||
/**
|
||||
@@ -37,6 +24,14 @@ import "@app/components/notifications/NotificationBell.css";
|
||||
* mean, so a new source or failure kind needs no change here. In core because both shells mount it.
|
||||
*/
|
||||
export function NotificationBell() {
|
||||
// A build with no notifications API gets no bell at all, rather than one that polls a
|
||||
// nonexistent endpoint forever to show nothing.
|
||||
const available = useNotificationsAvailable();
|
||||
if (!available) return null;
|
||||
return <MountedNotificationBell />;
|
||||
}
|
||||
|
||||
function MountedNotificationBell() {
|
||||
const { t } = useTranslation();
|
||||
const { notifications, unreadCount, documentStateFor, markAllSeen, refresh } =
|
||||
useNotifications();
|
||||
@@ -44,13 +39,9 @@ export function NotificationBell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const headingId = useId();
|
||||
/**
|
||||
* Where the new ones stop, frozen on open. An id rather than a count because opening marks
|
||||
* everything read, and because one arriving on a poll must land above the divider, not shift it.
|
||||
*/
|
||||
// Where the new ones stop, frozen when the panel opens (opening marks everything read).
|
||||
const [firstSeenId, setFirstSeenId] = useState<string | null>(null);
|
||||
// Fixed to the viewport: the workbench bar clips its overflow, so an absolutely positioned panel
|
||||
// would be cut off by its own toolbar.
|
||||
// Viewport-fixed, because the workbench bar clips its own overflow.
|
||||
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(
|
||||
null,
|
||||
);
|
||||
@@ -248,287 +239,3 @@ export function NotificationBell() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An action that asked for a password, with everything running it needs. */
|
||||
interface PasswordPrompt {
|
||||
offer: NotificationActionOffer;
|
||||
spec: ClientActionSpec;
|
||||
context: NotificationActionContext;
|
||||
/** The row's title, so the prompt can say which failure it is unlocking for. */
|
||||
rowTitle: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's reason wins, being about the failure rather than this browser. Otherwise only what we
|
||||
* actually looked up, so a row we never probed is never called absent.
|
||||
*/
|
||||
/** The kind's own sentence, sharing the portal's copy. Empty for a kind this build has none for. */
|
||||
function summaryKeyOf(titleKey: string): string {
|
||||
return titleKey.replace(/\.title$/, ".description");
|
||||
}
|
||||
|
||||
function noteFor(
|
||||
notification: AppNotification,
|
||||
documentState: NotificationDocumentState,
|
||||
withheldReasonKey: string | null,
|
||||
t: TFunction,
|
||||
): string | null {
|
||||
if (withheldReasonKey)
|
||||
return t(withheldReasonKey, {
|
||||
defaultValue: t(
|
||||
"notifications.action.unavailable",
|
||||
"Not available for this notification.",
|
||||
),
|
||||
});
|
||||
if (notification.ownership !== "MINE" || documentState.hasLocalFile)
|
||||
return null;
|
||||
if (!notification.fileId)
|
||||
return t(
|
||||
"notifications.noDocumentLinked",
|
||||
"This failure is not linked to a specific document, so it cannot be opened or retried here.",
|
||||
);
|
||||
return isResolvableHere(notification)
|
||||
? t(
|
||||
"notifications.notOnThisDevice",
|
||||
"This document is not on this device, so it cannot be opened or retried here.",
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: AppNotification;
|
||||
unread: boolean;
|
||||
documentState: NotificationDocumentState;
|
||||
registry: ClientActionRegistry;
|
||||
onDismissPanel: () => void;
|
||||
/** Hand a password-collecting action to the panel, which owns the prompt. */
|
||||
onRequestPassword: (prompt: PasswordPrompt) => void;
|
||||
}
|
||||
|
||||
/** Its own component because the last attempt's message and the copy state are per-row. */
|
||||
function NotificationItem({
|
||||
notification,
|
||||
unread,
|
||||
documentState,
|
||||
registry,
|
||||
onDismissPanel,
|
||||
onRequestPassword,
|
||||
}: NotificationItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const title = t(notification.titleKey, notification.defaultTitle);
|
||||
const context: NotificationActionContext = {
|
||||
notification,
|
||||
hasLocalFile: documentState.hasLocalFile,
|
||||
retryPayload: documentState.retryPayload,
|
||||
};
|
||||
|
||||
const { primary, secondary, overflow, withheldReasonKey } = promoteActions(
|
||||
notification.actions,
|
||||
(offer) => {
|
||||
const spec = registry[offer.id];
|
||||
// An id this build has never heard of: skipped, not rendered unwired. The server ships new kinds
|
||||
// and their actions ahead of the clients that understand them.
|
||||
if (!spec) return false;
|
||||
return spec.available(context);
|
||||
},
|
||||
);
|
||||
|
||||
const labelOf = (offer: NotificationActionOffer) =>
|
||||
t(offer.labelKey, offer.defaultLabel);
|
||||
|
||||
const run = async (offer: NotificationActionOffer) => {
|
||||
if (busy) return;
|
||||
setMessage(null);
|
||||
|
||||
const spec = registry[offer.id];
|
||||
if (!spec) return;
|
||||
// A password action is handed to the panel, which prompts for it and runs it from there.
|
||||
if (spec.needsPassword) {
|
||||
onRequestPassword({ offer, spec, context, rowTitle: title });
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(offer.id);
|
||||
const outcome = await spec.run(context);
|
||||
setBusy(null);
|
||||
if (outcome && !outcome.ok) {
|
||||
setMessage(
|
||||
outcome.message ??
|
||||
t(
|
||||
"notifications.action.failed",
|
||||
"That did not work. Try again in a moment.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (spec.closesPanel) onDismissPanel();
|
||||
};
|
||||
|
||||
const copyDetail = async () => {
|
||||
if (!notification.detail) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(notification.detail);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
// No clipboard permission, and the message is on screen and selectable anyway.
|
||||
}
|
||||
};
|
||||
|
||||
const note = noteFor(notification, documentState, withheldReasonKey, t);
|
||||
const summary = t(summaryKeyOf(notification.titleKey), { defaultValue: "" });
|
||||
|
||||
return (
|
||||
<li
|
||||
className="notification-bell__item"
|
||||
data-severity={notification.severity.toLowerCase()}
|
||||
>
|
||||
{unread && (
|
||||
<span
|
||||
className="notification-bell__dot"
|
||||
aria-label={t("notifications.unread", "Unread")}
|
||||
/>
|
||||
)}
|
||||
<span className="notification-bell__item-title">{title}</span>
|
||||
{notification.occurrences > 1 && (
|
||||
<span className="notification-bell__count">
|
||||
{t("notifications.occurrences", {
|
||||
count: notification.occurrences,
|
||||
defaultValue: "{{count}} times",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{summary && <span className="notification-bell__detail">{summary}</span>}
|
||||
|
||||
{note && <span className="notification-bell__note">{note}</span>}
|
||||
|
||||
{/* Two buttons at most, then a menu: the row's own answer, one runner-up, and the rest tucked
|
||||
out of the way so a row of near-equal buttons never competes for the click. */}
|
||||
{primary && (
|
||||
<span className="notification-bell__actions">
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
rowTitle={title}
|
||||
label={labelOf(primary)}
|
||||
busy={busy === primary.id}
|
||||
onRun={() => void run(primary)}
|
||||
/>
|
||||
{secondary && (
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
rowTitle={title}
|
||||
label={labelOf(secondary)}
|
||||
busy={busy === secondary.id}
|
||||
onRun={() => void run(secondary)}
|
||||
/>
|
||||
)}
|
||||
{(overflow.length > 0 || notification.detail) && (
|
||||
<Menu withinPortal position="bottom-end" shadow="md" width={180}>
|
||||
<Menu.Target>
|
||||
<Tooltip
|
||||
label={t("notifications.action.more", "More options")}
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
className="notification-bell__more"
|
||||
aria-label={`${t("notifications.action.more", "More options")}: ${title}`}
|
||||
>
|
||||
<MoreIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown className="notification-bell__menu">
|
||||
{overflow.map((offer) => (
|
||||
<Menu.Item
|
||||
key={offer.id}
|
||||
disabled={busy === offer.id}
|
||||
onClick={() => void run(offer)}
|
||||
>
|
||||
{labelOf(offer)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
{notification.detail && (
|
||||
<Menu.Item
|
||||
closeMenuOnClick={false}
|
||||
onClick={() => void copyDetail()}
|
||||
>
|
||||
{copied
|
||||
? t("notifications.action.copiedLog", "Copied")
|
||||
: t("notifications.action.copyLog", "Copy log")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<span className="notification-bell__message" role="alert">
|
||||
{message}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
/** Solid for the row's answer, outlined for its runner-up, ghost for the rest. */
|
||||
variant: "primary" | "secondary" | "tertiary";
|
||||
rowTitle: string;
|
||||
label: string;
|
||||
busy: boolean;
|
||||
onRun: () => void;
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
variant,
|
||||
rowTitle,
|
||||
label,
|
||||
busy,
|
||||
onRun,
|
||||
}: ActionButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size="sm"
|
||||
fontSize="xs"
|
||||
className="notification-bell__cta"
|
||||
disabled={busy}
|
||||
// Every row's buttons read alike, so the label alone would not say which failure this acts on.
|
||||
aria-label={`${label}: ${rowTitle}`}
|
||||
onClick={onRun}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const ICON_PROPS = {
|
||||
width: 14,
|
||||
height: 14,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
"aria-hidden": true,
|
||||
};
|
||||
|
||||
function MoreIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS} strokeWidth={2.5}>
|
||||
<circle cx="5" cy="12" r="0.5" />
|
||||
<circle cx="12" cy="12" r="0.5" />
|
||||
<circle cx="19" cy="12" r="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Button } from "@app/ui";
|
||||
import { isResolvableHere } from "@app/hooks/useNotifications";
|
||||
import type { NotificationDocumentState } from "@app/hooks/useNotifications";
|
||||
import type {
|
||||
ClientActionRegistry,
|
||||
ClientActionSpec,
|
||||
NotificationActionContext,
|
||||
} from "@app/components/notifications/notificationActions";
|
||||
import { promoteActions } from "@app/components/notifications/notificationActionSlots";
|
||||
import type {
|
||||
AppNotification,
|
||||
NotificationActionOffer,
|
||||
} from "@app/services/notifications";
|
||||
|
||||
/** An action that asked for a password, with everything running it needs. */
|
||||
export interface PasswordPrompt {
|
||||
offer: NotificationActionOffer;
|
||||
spec: ClientActionSpec;
|
||||
context: NotificationActionContext;
|
||||
/** The row's title, so the prompt can say which failure it is unlocking for. */
|
||||
rowTitle: string;
|
||||
}
|
||||
|
||||
/** The kind's own sentence, sharing the portal's copy. Empty for a kind this build has none for. */
|
||||
function summaryKeyOf(titleKey: string): string {
|
||||
return titleKey.replace(/\.title$/, ".description");
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's reason wins, being about the failure rather than this browser. Otherwise only what we
|
||||
* actually looked up, so a row we never probed is never called absent.
|
||||
*/
|
||||
function noteFor(
|
||||
notification: AppNotification,
|
||||
documentState: NotificationDocumentState,
|
||||
withheldReasonKey: string | null,
|
||||
t: TFunction,
|
||||
): string | null {
|
||||
if (withheldReasonKey)
|
||||
return t(withheldReasonKey, {
|
||||
defaultValue: t(
|
||||
"notifications.action.unavailable",
|
||||
"Not available for this notification.",
|
||||
),
|
||||
});
|
||||
if (notification.ownership !== "MINE" || documentState.hasLocalFile)
|
||||
return null;
|
||||
if (!notification.fileId)
|
||||
return t(
|
||||
"notifications.noDocumentLinked",
|
||||
"This failure is not linked to a specific document, so it cannot be opened or retried here.",
|
||||
);
|
||||
return isResolvableHere(notification)
|
||||
? t(
|
||||
"notifications.notOnThisDevice",
|
||||
"This document is not on this device, so it cannot be opened or retried here.",
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: AppNotification;
|
||||
unread: boolean;
|
||||
documentState: NotificationDocumentState;
|
||||
registry: ClientActionRegistry;
|
||||
onDismissPanel: () => void;
|
||||
/** Hand a password-collecting action to the panel, which owns the prompt. */
|
||||
onRequestPassword: (prompt: PasswordPrompt) => void;
|
||||
}
|
||||
|
||||
/** Its own component because the last attempt's message and the copy state are per-row. */
|
||||
export function NotificationItem({
|
||||
notification,
|
||||
unread,
|
||||
documentState,
|
||||
registry,
|
||||
onDismissPanel,
|
||||
onRequestPassword,
|
||||
}: NotificationItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const title = t(notification.titleKey, notification.defaultTitle);
|
||||
const context: NotificationActionContext = {
|
||||
notification,
|
||||
hasLocalFile: documentState.hasLocalFile,
|
||||
retryPayload: documentState.retryPayload,
|
||||
};
|
||||
|
||||
const { primary, secondary, overflow, withheldReasonKey } = promoteActions(
|
||||
notification.actions,
|
||||
(offer) => {
|
||||
const spec = registry[offer.id];
|
||||
// An id this build has never heard of: skipped, not rendered unwired. The server ships new kinds
|
||||
// and their actions ahead of the clients that understand them.
|
||||
if (!spec) return false;
|
||||
return spec.available(context);
|
||||
},
|
||||
);
|
||||
|
||||
const labelOf = (offer: NotificationActionOffer) =>
|
||||
t(offer.labelKey, offer.defaultLabel);
|
||||
|
||||
const run = async (offer: NotificationActionOffer) => {
|
||||
if (busy) return;
|
||||
setMessage(null);
|
||||
|
||||
const spec = registry[offer.id];
|
||||
if (!spec) return;
|
||||
// A password action is handed to the panel, which prompts for it and runs it from there.
|
||||
if (spec.needsPassword) {
|
||||
onRequestPassword({ offer, spec, context, rowTitle: title });
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(offer.id);
|
||||
const outcome = await spec.run(context);
|
||||
setBusy(null);
|
||||
if (outcome && !outcome.ok) {
|
||||
setMessage(
|
||||
outcome.message ??
|
||||
t(
|
||||
"notifications.action.failed",
|
||||
"That did not work. Try again in a moment.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (spec.closesPanel) onDismissPanel();
|
||||
};
|
||||
|
||||
const copyDetail = async () => {
|
||||
if (!notification.detail) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(notification.detail);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
// No clipboard permission, and the message is on screen and selectable anyway.
|
||||
}
|
||||
};
|
||||
|
||||
const note = noteFor(notification, documentState, withheldReasonKey, t);
|
||||
const summary = t(summaryKeyOf(notification.titleKey), { defaultValue: "" });
|
||||
|
||||
return (
|
||||
<li
|
||||
className="notification-bell__item"
|
||||
data-severity={notification.severity.toLowerCase()}
|
||||
>
|
||||
{unread && (
|
||||
<span
|
||||
className="notification-bell__dot"
|
||||
aria-label={t("notifications.unread", "Unread")}
|
||||
/>
|
||||
)}
|
||||
<span className="notification-bell__item-title">{title}</span>
|
||||
{notification.occurrences > 1 && (
|
||||
<span className="notification-bell__count">
|
||||
{t("notifications.occurrences", {
|
||||
count: notification.occurrences,
|
||||
defaultValue: "{{count}} times",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{summary && <span className="notification-bell__detail">{summary}</span>}
|
||||
|
||||
{note && <span className="notification-bell__note">{note}</span>}
|
||||
|
||||
{/* Two buttons at most, then a menu: the row's own answer, one runner-up, and the rest tucked
|
||||
out of the way so a row of near-equal buttons never competes for the click. */}
|
||||
{primary && (
|
||||
<span className="notification-bell__actions">
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
rowTitle={title}
|
||||
label={labelOf(primary)}
|
||||
busy={busy === primary.id}
|
||||
onRun={() => void run(primary)}
|
||||
/>
|
||||
{secondary && (
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
rowTitle={title}
|
||||
label={labelOf(secondary)}
|
||||
busy={busy === secondary.id}
|
||||
onRun={() => void run(secondary)}
|
||||
/>
|
||||
)}
|
||||
{(overflow.length > 0 || notification.detail) && (
|
||||
<Menu withinPortal position="bottom-end" shadow="md" width={180}>
|
||||
<Menu.Target>
|
||||
<Tooltip
|
||||
label={t("notifications.action.more", "More options")}
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
className="notification-bell__more"
|
||||
aria-label={`${t("notifications.action.more", "More options")}: ${title}`}
|
||||
>
|
||||
<MoreIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown className="notification-bell__menu">
|
||||
{overflow.map((offer) => (
|
||||
<Menu.Item
|
||||
key={offer.id}
|
||||
disabled={busy === offer.id}
|
||||
onClick={() => void run(offer)}
|
||||
>
|
||||
{labelOf(offer)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
{notification.detail && (
|
||||
<Menu.Item
|
||||
closeMenuOnClick={false}
|
||||
onClick={() => void copyDetail()}
|
||||
>
|
||||
{copied
|
||||
? t("notifications.action.copiedLog", "Copied")
|
||||
: t("notifications.action.copyLog", "Copy log")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<span className="notification-bell__message" role="alert">
|
||||
{message}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
/** Solid for the row's answer, outlined for its runner-up, ghost for the rest. */
|
||||
variant: "primary" | "secondary" | "tertiary";
|
||||
rowTitle: string;
|
||||
label: string;
|
||||
busy: boolean;
|
||||
onRun: () => void;
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
variant,
|
||||
rowTitle,
|
||||
label,
|
||||
busy,
|
||||
onRun,
|
||||
}: ActionButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size="sm"
|
||||
fontSize="xs"
|
||||
className="notification-bell__cta"
|
||||
disabled={busy}
|
||||
// Every row's buttons read alike, so the label alone would not say which failure this acts on.
|
||||
aria-label={`${label}: ${rowTitle}`}
|
||||
onClick={onRun}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const ICON_PROPS = {
|
||||
width: 14,
|
||||
height: 14,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
"aria-hidden": true,
|
||||
};
|
||||
|
||||
function MoreIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS} strokeWidth={2.5}>
|
||||
<circle cx="5" cy="12" r="0.5" />
|
||||
<circle cx="12" cy="12" r="0.5" />
|
||||
<circle cx="19" cy="12" r="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Whether this build has a notifications API to read. When it does not, the bell must not
|
||||
* mount at all: an unconditional mount would poll an endpoint that does not exist, leaving a
|
||||
* permanent timer and a 404 in the network log for nothing it could ever show.
|
||||
*
|
||||
* Core has no failure registry and no notification routes, so the answer here is no; a build
|
||||
* that ships them overrides this to say so.
|
||||
*/
|
||||
export function useNotificationsAvailable(): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -603,8 +603,7 @@ export default function WorkbenchBar({
|
||||
enforcingProgress={enforcingProgress}
|
||||
/>
|
||||
)}
|
||||
{/* Last in the globals, so it is the rightmost control. Workbench floats it instead
|
||||
when the bar is down, so the bell is reachable whether or not a file is open. */}
|
||||
{/* Last in the globals, so it is the rightmost control. */}
|
||||
<div className="workbench-bar-divider workbench-bar-globals-sep" />
|
||||
<NotificationBell />
|
||||
</div>
|
||||
|
||||
+11
-4
@@ -25,6 +25,13 @@ export default function WorkbenchBarMobileActions({
|
||||
}: WorkbenchBarActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const exportDisabled = actionsDisabled || policyEnforcing;
|
||||
const showPrint = currentView === "viewer";
|
||||
const showFileActions = !isCustomView;
|
||||
|
||||
// Custom workbench views own their content, so none of these apply. The
|
||||
// desktop cluster renders nothing at all in that case; without this the
|
||||
// trigger would still be there, opening an empty dropdown.
|
||||
if (!showPrint && !showFileActions) return null;
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={230} position="bottom-end">
|
||||
@@ -39,7 +46,7 @@ export default function WorkbenchBarMobileActions({
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{currentView === "viewer" && (
|
||||
{showPrint && (
|
||||
<Menu.Item
|
||||
leftSection={<PrintIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
disabled={exportDisabled}
|
||||
@@ -48,7 +55,7 @@ export default function WorkbenchBarMobileActions({
|
||||
{t("workbenchBar.print", "Print PDF")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCustomView && (
|
||||
{showFileActions && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
@@ -63,7 +70,7 @@ export default function WorkbenchBarMobileActions({
|
||||
{downloadLabel}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCustomView && saveAsIconName && (
|
||||
{showFileActions && saveAsIconName && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<LocalIcon icon={saveAsIconName} width="1.1rem" height="1.1rem" />
|
||||
@@ -74,7 +81,7 @@ export default function WorkbenchBarMobileActions({
|
||||
{t("workbenchBar.saveAs", "Save As")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCustomView && (
|
||||
{showFileActions && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
|
||||
@@ -282,6 +282,9 @@ export default function RightSidebar() {
|
||||
onShowAllTools={handleShowAllTools}
|
||||
onToolSelect={handleToolSelectWithTransition}
|
||||
compact={false}
|
||||
/* Mobile keeps the workbench bar - and with it the super search -
|
||||
on the other slide, so the list needs its own filter. */
|
||||
showSearch={isMobile}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
|
||||
@@ -183,6 +183,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* In-panel tool filter. Aligned with .tool-picker__compact's inline padding so
|
||||
the field lines up with the tool rows underneath it. */
|
||||
.tool-panel__search {
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem var(--mantine-spacing-sm) 0;
|
||||
}
|
||||
|
||||
.tool-panel__search .search-input-container {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import ToolPicker from "@app/components/tools/ToolPicker";
|
||||
import SearchResults from "@app/components/tools/SearchResults";
|
||||
import ToolRenderer from "@app/components/tools/ToolRenderer";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
|
||||
@@ -20,6 +21,11 @@ interface ToolPanelProps {
|
||||
onToolSelect?: (id: ToolId) => void;
|
||||
/** Whether to render the compact (favourites + recommended only) view. */
|
||||
compact?: boolean;
|
||||
/**
|
||||
* Render a tool filter at the head of the panel. Set where the workbench
|
||||
* bar's super search is out of reach, so the list stays searchable in place.
|
||||
*/
|
||||
showSearch?: boolean;
|
||||
}
|
||||
|
||||
/** Tool list and renderer for the right rail; rail chrome lives in RightSidebar. */
|
||||
@@ -28,24 +34,44 @@ export default function ToolPanel({
|
||||
onShowAllTools,
|
||||
onToolSelect,
|
||||
compact: compactProp,
|
||||
showSearch = false,
|
||||
}: ToolPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
leftPanelView,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
selectedToolKey,
|
||||
handleToolSelect,
|
||||
setPreviewFile,
|
||||
} = useToolWorkflow();
|
||||
const selectTool = onToolSelect ?? handleToolSelect;
|
||||
|
||||
// Only offer the filter over the list itself; once a tool is open the panel
|
||||
// belongs to that tool. Deriving the results branch from the same flag keeps
|
||||
// the input and what it filters from drifting apart.
|
||||
const panelSearch = showSearch && leftPanelView === "toolPicker";
|
||||
const searching = searchQuery.trim().length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Viewer mode tools — annotate, redact, form fill */}
|
||||
<ToolPanelViewerBar />
|
||||
|
||||
{allToolsView && searchQuery.trim().length > 0 ? (
|
||||
{panelSearch && (
|
||||
<div className="tool-panel__search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searching && (allToolsView || panelSearch) ? (
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
@@ -59,7 +85,7 @@ export default function ToolPanel({
|
||||
selectedToolKey={selectedToolKey}
|
||||
onSelect={(id) => selectTool(id as ToolId)}
|
||||
filteredTools={filteredTools}
|
||||
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
|
||||
isSearching={searching}
|
||||
compact={compactProp ?? !allToolsView}
|
||||
onShowAllTools={onShowAllTools}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The bundled Material Symbols set only carries the filled variant, which reads as permanently
|
||||
* ringing. Mirrors the portal's own icon rather than importing it: core cannot reach into portal.
|
||||
* An outline bell. The bundled Material Symbols set only carries the filled variant,
|
||||
* which reads as permanently ringing.
|
||||
*/
|
||||
export function BellIcon({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "@app/ui/Button";
|
||||
export * from "@app/ui/ActionIcon";
|
||||
export * from "@app/ui/BellIcon";
|
||||
export * from "@app/ui/Logo";
|
||||
export * from "@app/ui/FilePicker";
|
||||
export * from "@app/ui/SegmentedControl";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* This build ships the failure registry and the notification routes, so the bell has
|
||||
* something to read and may mount.
|
||||
*/
|
||||
export function useNotificationsAvailable(): boolean {
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user