feat: add Apprise availability rescan (#3551)
This commit is contained in:
@@ -24,6 +24,8 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
const PAGER_URL = "json://pager";
|
||||
const CLARIFICATION_EVENT = "session.clarification_requested";
|
||||
const SAVED_EVENT = "task.completed";
|
||||
const DRAFT_NAME = "Draft name";
|
||||
const SEMANTIC_NOTIFICATION_EVENTS = [CLARIFICATION_EVENT, "session.turn_finished"];
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
@@ -39,21 +41,29 @@ const savedProvider: NotificationProvider = {
|
||||
type: "apprise",
|
||||
config: { urls: ["json://saved"] },
|
||||
enabled: true,
|
||||
events: ["task.completed"],
|
||||
events: [SAVED_EVENT],
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
const localProvider: NotificationProvider = {
|
||||
...savedProvider,
|
||||
id: "provider-local",
|
||||
name: "Local provider",
|
||||
type: "local",
|
||||
config: {},
|
||||
};
|
||||
|
||||
let notificationProviders = [savedProvider];
|
||||
let notificationEvents = ["task.completed"];
|
||||
let notificationEvents = [SAVED_EVENT];
|
||||
let notificationProvidersLoaded = true;
|
||||
let notificationAppriseAvailable = true;
|
||||
const originalNotification = globalThis.Notification;
|
||||
|
||||
vi.mock("@/hooks/domains/settings/use-notification-providers", () => ({
|
||||
useNotificationProviders: () => ({
|
||||
providers: notificationProviders,
|
||||
events: notificationEvents,
|
||||
appriseAvailable: true,
|
||||
appriseAvailable: notificationAppriseAvailable,
|
||||
loaded: notificationProvidersLoaded,
|
||||
}),
|
||||
}));
|
||||
@@ -90,8 +100,9 @@ const createdProvider: NotificationProvider = {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
notificationProviders = [savedProvider];
|
||||
notificationEvents = ["task.completed"];
|
||||
notificationEvents = [SAVED_EVENT];
|
||||
notificationProvidersLoaded = true;
|
||||
notificationAppriseAvailable = true;
|
||||
mocks.nativeAvailable.mockReturnValue(true);
|
||||
mocks.createNotificationProvider.mockResolvedValue(createdProvider);
|
||||
});
|
||||
@@ -152,19 +163,19 @@ describe("notification provider draft hydration", () => {
|
||||
|
||||
it("does not replace an edited draft when providers refresh", () => {
|
||||
const { result, rerender } = renderHook(useHarness);
|
||||
act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, "Draft name"));
|
||||
act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME));
|
||||
|
||||
const refreshedProvider = { ...savedProvider, name: "Server refresh" };
|
||||
notificationProviders = [refreshedProvider];
|
||||
rerender();
|
||||
|
||||
expect(result.current.state.providers[0]?.name).toBe("Draft name");
|
||||
expect(result.current.state.providers[0]?.name).toBe(DRAFT_NAME);
|
||||
expect(result.current.state.baselineProviders[0]?.name).toBe(savedProvider.name);
|
||||
});
|
||||
|
||||
it("hydrates a deferred provider refresh after discarding the draft", () => {
|
||||
const { result, rerender } = renderHook(useHarness);
|
||||
act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, "Draft name"));
|
||||
act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME));
|
||||
const refreshedProvider = { ...savedProvider, name: "Server refresh" };
|
||||
notificationProviders = [refreshedProvider];
|
||||
rerender();
|
||||
@@ -174,6 +185,37 @@ describe("notification provider draft hydration", () => {
|
||||
expect(result.current.state.providers).toEqual([refreshedProvider]);
|
||||
expect(result.current.state.baselineProviders).toEqual([refreshedProvider]);
|
||||
});
|
||||
|
||||
it("updates availability without replacing dirty drafts or pending removals", () => {
|
||||
notificationProviders = [savedProvider, localProvider];
|
||||
const { result, rerender } = renderHook(useHarness);
|
||||
|
||||
act(() => {
|
||||
result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME);
|
||||
result.current.actions.handleAppriseEdit(savedProvider.id, PAGER_URL);
|
||||
result.current.actions.handleToggleEvent(savedProvider, CLARIFICATION_EVENT);
|
||||
result.current.actions.handleDeleteProvider(localProvider.id);
|
||||
result.current.actions.openAppriseForm("create");
|
||||
result.current.state.setAppriseName("New provider");
|
||||
result.current.state.setAppriseUrls("json://new");
|
||||
});
|
||||
|
||||
notificationAppriseAvailable = false;
|
||||
rerender();
|
||||
|
||||
expect(result.current.state.appriseAvailable).toBe(false);
|
||||
expect(result.current.state.providers).toEqual([
|
||||
expect.objectContaining({
|
||||
id: savedProvider.id,
|
||||
name: DRAFT_NAME,
|
||||
config: { urls: [PAGER_URL] },
|
||||
events: [SAVED_EVENT, CLARIFICATION_EVENT],
|
||||
}),
|
||||
]);
|
||||
expect(result.current.state.pendingDeletes).toEqual(new Set([localProvider.id]));
|
||||
expect(result.current.state.appriseName).toBe("New provider");
|
||||
expect(result.current.state.appriseUrls).toBe("json://new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification provider draft saving", () => {
|
||||
@@ -246,6 +288,38 @@ describe("notification provider draft saving", () => {
|
||||
expect(result.current.state.providers).toEqual([savedProvider]);
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("does not overwrite a rescan that completes during a provider save", async () => {
|
||||
let resolveUpdate: (provider: NotificationProvider) => void = () => undefined;
|
||||
const updatePromise = new Promise<NotificationProvider>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
});
|
||||
mocks.updateNotificationProvider.mockReturnValueOnce(updatePromise);
|
||||
const { result, rerender } = renderHook(useHarness);
|
||||
|
||||
act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME));
|
||||
let savePromise: Promise<unknown>;
|
||||
act(() => {
|
||||
savePromise = result.current.saveRequest.run();
|
||||
});
|
||||
await waitFor(() => expect(mocks.updateNotificationProvider).toHaveBeenCalledOnce());
|
||||
|
||||
notificationAppriseAvailable = false;
|
||||
rerender();
|
||||
|
||||
await act(async () => {
|
||||
resolveUpdate({ ...savedProvider, name: DRAFT_NAME });
|
||||
await savePromise;
|
||||
});
|
||||
|
||||
const lastSnapshot = mocks.setNotificationProviders.mock.lastCall?.[0];
|
||||
expect(lastSnapshot).toEqual({
|
||||
items: [{ ...savedProvider, name: DRAFT_NAME }],
|
||||
events: [SAVED_EVENT],
|
||||
loaded: true,
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification permission actions", () => {
|
||||
|
||||
@@ -143,20 +143,80 @@ function isSameHydrationSource(
|
||||
);
|
||||
}
|
||||
|
||||
type NotificationDraftHydrationOptions = {
|
||||
loaded: boolean;
|
||||
source: NotificationHydrationSource;
|
||||
draft: NotificationDraft;
|
||||
setProviders: (value: NotificationProvider[]) => void;
|
||||
setBaselineProviders: (value: NotificationProvider[]) => void;
|
||||
setNotificationEvents: (value: string[]) => void;
|
||||
setAppriseEdits: (value: Record<string, string>) => void;
|
||||
setAppriseNameEdits: (value: Record<string, string>) => void;
|
||||
};
|
||||
|
||||
function useHydrateNotificationDraft({
|
||||
loaded,
|
||||
source,
|
||||
draft,
|
||||
setProviders,
|
||||
setBaselineProviders,
|
||||
setNotificationEvents,
|
||||
setAppriseEdits,
|
||||
setAppriseNameEdits,
|
||||
}: NotificationDraftHydrationOptions) {
|
||||
const hydratedSource = useRef<NotificationHydrationSource | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded || isSameHydrationSource(hydratedSource.current, source)) return;
|
||||
if (hydratedSource.current && hasDraftChanges(draft)) return;
|
||||
|
||||
hydratedSource.current = source;
|
||||
const nextProviders = source.providers ?? [];
|
||||
setProviders(nextProviders);
|
||||
setBaselineProviders(nextProviders);
|
||||
setNotificationEvents(source.events ?? []);
|
||||
const edits = buildAppriseEdits(nextProviders);
|
||||
setAppriseEdits(edits.urls);
|
||||
setAppriseNameEdits(edits.names);
|
||||
}, [
|
||||
draft.appriseEdits,
|
||||
draft.appriseFormMode,
|
||||
draft.appriseNameEdits,
|
||||
draft.baselineProviders,
|
||||
draft.pendingDeletes,
|
||||
draft.providers,
|
||||
draft.showAppriseForm,
|
||||
loaded,
|
||||
setAppriseEdits,
|
||||
setAppriseNameEdits,
|
||||
setBaselineProviders,
|
||||
setNotificationEvents,
|
||||
setProviders,
|
||||
source.appriseAvailable,
|
||||
source.events,
|
||||
source.providers,
|
||||
]);
|
||||
}
|
||||
|
||||
export function useNotificationsState() {
|
||||
const {
|
||||
providers: storeProviders,
|
||||
events: storeEvents,
|
||||
appriseAvailable: storeAppriseAvailable,
|
||||
loaded,
|
||||
loading,
|
||||
rescanApprise,
|
||||
appriseRescanPending,
|
||||
appriseRescanError,
|
||||
appriseRescanResult,
|
||||
} = useNotificationProviders();
|
||||
const appriseAvailable = storeAppriseAvailable ?? false;
|
||||
const setNotificationProviders = useAppStore((state) => state.setNotificationProviders);
|
||||
const [providers, setProviders] = useState<NotificationProvider[]>(() => storeProviders ?? []);
|
||||
const [baselineProviders, setBaselineProviders] = useState<NotificationProvider[]>(
|
||||
() => storeProviders ?? [],
|
||||
);
|
||||
const [notificationEvents, setNotificationEvents] = useState<string[]>(() => storeEvents ?? []);
|
||||
const [appriseAvailable, setAppriseAvailable] = useState(() => storeAppriseAvailable ?? true);
|
||||
const [appriseName, setAppriseName] = useState("");
|
||||
const [appriseUrls, setAppriseUrls] = useState("");
|
||||
const [appriseEdits, setAppriseEdits] = useState<Record<string, string>>(
|
||||
@@ -169,19 +229,14 @@ export function useNotificationsState() {
|
||||
const [appriseFormMode, setAppriseFormMode] = useState<AppriseFormMode>("create");
|
||||
const [activeAppriseId, setActiveAppriseId] = useState<string | null>(null);
|
||||
const [pendingDeletes, setPendingDeletes] = useState<Set<string>>(new Set());
|
||||
const hydratedSource = useRef<NotificationHydrationSource | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
const source = {
|
||||
useHydrateNotificationDraft({
|
||||
loaded,
|
||||
source: {
|
||||
providers: storeProviders,
|
||||
events: storeEvents,
|
||||
appriseAvailable: storeAppriseAvailable,
|
||||
};
|
||||
if (isSameHydrationSource(hydratedSource.current, source)) {
|
||||
return;
|
||||
}
|
||||
const draft = {
|
||||
},
|
||||
draft: {
|
||||
providers,
|
||||
baselineProviders,
|
||||
appriseEdits,
|
||||
@@ -189,32 +244,13 @@ export function useNotificationsState() {
|
||||
pendingDeletes,
|
||||
showAppriseForm,
|
||||
appriseFormMode,
|
||||
};
|
||||
if (hydratedSource.current && hasDraftChanges(draft)) {
|
||||
return;
|
||||
}
|
||||
hydratedSource.current = source;
|
||||
const nextProviders = storeProviders ?? [];
|
||||
setProviders(nextProviders);
|
||||
setBaselineProviders(nextProviders);
|
||||
setNotificationEvents(storeEvents ?? []);
|
||||
setAppriseAvailable(storeAppriseAvailable ?? true);
|
||||
const edits = buildAppriseEdits(nextProviders);
|
||||
setAppriseEdits(edits.urls);
|
||||
setAppriseNameEdits(edits.names);
|
||||
}, [
|
||||
appriseEdits,
|
||||
appriseFormMode,
|
||||
appriseNameEdits,
|
||||
baselineProviders,
|
||||
loaded,
|
||||
pendingDeletes,
|
||||
providers,
|
||||
showAppriseForm,
|
||||
storeAppriseAvailable,
|
||||
storeEvents,
|
||||
storeProviders,
|
||||
]);
|
||||
},
|
||||
setProviders,
|
||||
setBaselineProviders,
|
||||
setNotificationEvents,
|
||||
setAppriseEdits,
|
||||
setAppriseNameEdits,
|
||||
});
|
||||
return {
|
||||
providers,
|
||||
setProviders,
|
||||
@@ -222,8 +258,13 @@ export function useNotificationsState() {
|
||||
setBaselineProviders,
|
||||
notificationEvents,
|
||||
setNotificationEvents,
|
||||
loaded,
|
||||
loading,
|
||||
appriseAvailable,
|
||||
setAppriseAvailable,
|
||||
rescanApprise,
|
||||
appriseRescanPending,
|
||||
appriseRescanError,
|
||||
appriseRescanResult,
|
||||
appriseName,
|
||||
setAppriseName,
|
||||
appriseUrls,
|
||||
@@ -304,7 +345,6 @@ export function useSaveRequest(state: NotificationsState) {
|
||||
setNotificationProviders({
|
||||
items: nextProviders,
|
||||
events: state.notificationEvents,
|
||||
appriseAvailable: state.appriseAvailable,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent } from "react";
|
||||
import { IconBell, IconRefresh } from "@tabler/icons-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { SettingsTarget } from "@/components/settings/settings-target";
|
||||
import { type AppriseFormMode } from "@/components/settings/notifications-settings-actions";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import type { NotificationProvider } from "@/lib/types/http";
|
||||
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
|
||||
|
||||
function AppriseProviderCardActions({
|
||||
provider,
|
||||
onOpenForm,
|
||||
onDeleteProvider,
|
||||
onTestProvider,
|
||||
}: {
|
||||
provider: NotificationProvider;
|
||||
onOpenForm: (mode: AppriseFormMode, provider: NotificationProvider) => void;
|
||||
onDeleteProvider: (providerId: string) => void;
|
||||
onTestProvider: (providerId: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 cursor-pointer"
|
||||
aria-label={t("settings:sendTestNotificationFor", { name: provider.name })}
|
||||
onClick={() => void onTestProvider(provider.id)}
|
||||
>
|
||||
<IconBell className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings:sendTestNotification")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onOpenForm("edit", provider)}
|
||||
>
|
||||
{t("settings:edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onDeleteProvider(provider.id)}
|
||||
>
|
||||
{t("settings:remove")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppriseProviderList({
|
||||
providers,
|
||||
baselineProviders,
|
||||
appriseFormMode,
|
||||
activeAppriseId,
|
||||
appriseName,
|
||||
appriseUrls,
|
||||
onNameChange,
|
||||
onUrlsChange,
|
||||
onAppriseNameEdit,
|
||||
onAppriseEdit,
|
||||
onOpenForm,
|
||||
onCloseForm,
|
||||
onCancelForm,
|
||||
onDeleteProvider,
|
||||
onTestProvider,
|
||||
onTextareaInput,
|
||||
}: {
|
||||
providers: NotificationProvider[];
|
||||
baselineProviders: NotificationProvider[];
|
||||
appriseFormMode: AppriseFormMode;
|
||||
activeAppriseId: string | null;
|
||||
appriseName: string;
|
||||
appriseUrls: string;
|
||||
onNameChange: (value: string) => void;
|
||||
onUrlsChange: (value: string) => void;
|
||||
onAppriseNameEdit: (providerId: string, value: string) => void;
|
||||
onAppriseEdit: (providerId: string, value: string) => void;
|
||||
onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void;
|
||||
onCloseForm: () => void;
|
||||
onCancelForm: () => void;
|
||||
onDeleteProvider: (providerId: string) => void;
|
||||
onTestProvider: (providerId: string) => Promise<void>;
|
||||
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{providers.map((provider) => {
|
||||
const isEditing = appriseFormMode === "edit" && activeAppriseId === provider.id;
|
||||
const baseline = baselineProviders.find((candidate) => candidate.id === provider.id);
|
||||
const nameIsDirty = isEditing && provider.name !== baseline?.name;
|
||||
const urlsIsDirty =
|
||||
isEditing &&
|
||||
JSON.stringify(provider.config?.urls ?? []) !==
|
||||
JSON.stringify(baseline?.config?.urls ?? []);
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="rounded-lg border border-muted p-4 space-y-3"
|
||||
data-settings-dirty={nameIsDirty || urlsIsDirty}
|
||||
data-settings-dirty-level="container"
|
||||
>
|
||||
{isEditing ? (
|
||||
<AppriseProviderForm
|
||||
mode="edit"
|
||||
name={appriseName}
|
||||
urls={appriseUrls}
|
||||
onNameChange={(value) => {
|
||||
onNameChange(value);
|
||||
onAppriseNameEdit(provider.id, value);
|
||||
}}
|
||||
onUrlsChange={(value) => {
|
||||
onUrlsChange(value);
|
||||
onAppriseEdit(provider.id, value);
|
||||
}}
|
||||
onSubmit={onCloseForm}
|
||||
onCancel={onCancelForm}
|
||||
onInput={onTextareaInput}
|
||||
nameIsDirty={nameIsDirty}
|
||||
urlsIsDirty={urlsIsDirty}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1 flex-1">
|
||||
<div className="font-medium">{provider.name}</div>
|
||||
{/* Product name — never translated (docs/i18n.md "Do not translate"). */}
|
||||
<div className="text-xs text-muted-foreground">Apprise</div>
|
||||
</div>
|
||||
<AppriseProviderCardActions
|
||||
provider={provider}
|
||||
onOpenForm={onOpenForm}
|
||||
onDeleteProvider={onDeleteProvider}
|
||||
onTestProvider={onTestProvider}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ExternalProvidersSectionProps = {
|
||||
appriseAvailable: boolean;
|
||||
appriseProviders: NotificationProvider[];
|
||||
baselineProviders: NotificationProvider[];
|
||||
appriseFormMode: AppriseFormMode;
|
||||
activeAppriseId: string | null;
|
||||
appriseName: string;
|
||||
appriseUrls: string;
|
||||
showAppriseForm: boolean;
|
||||
notificationProvidersLoaded: boolean;
|
||||
notificationProvidersLoading: boolean;
|
||||
appriseRescanPending: boolean;
|
||||
appriseRescanError: boolean;
|
||||
appriseRescanResult: boolean | null;
|
||||
setAppriseName: (v: string) => void;
|
||||
setAppriseUrls: (v: string) => void;
|
||||
onAppriseNameEdit: (id: string, v: string) => void;
|
||||
onAppriseEdit: (id: string, v: string) => void;
|
||||
onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void;
|
||||
onCloseForm: () => void;
|
||||
onCancelForm: () => void;
|
||||
onDeleteProvider: (id: string) => void;
|
||||
onTestProvider: (id: string) => Promise<void>;
|
||||
onRescanApprise: () => Promise<void>;
|
||||
};
|
||||
|
||||
function AppriseRescanControls({
|
||||
notificationProvidersLoaded,
|
||||
notificationProvidersLoading,
|
||||
appriseRescanPending,
|
||||
appriseRescanError,
|
||||
appriseRescanResult,
|
||||
onRescanApprise,
|
||||
}: Pick<
|
||||
ExternalProvidersSectionProps,
|
||||
| "notificationProvidersLoaded"
|
||||
| "notificationProvidersLoading"
|
||||
| "appriseRescanPending"
|
||||
| "appriseRescanError"
|
||||
| "appriseRescanResult"
|
||||
| "onRescanApprise"
|
||||
>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-start gap-1 md:items-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 cursor-pointer md:min-h-9 [@media(pointer:coarse)]:min-h-11"
|
||||
data-testid="apprise-rescan"
|
||||
disabled={
|
||||
!notificationProvidersLoaded || notificationProvidersLoading || appriseRescanPending
|
||||
}
|
||||
onClick={() => void onRescanApprise()}
|
||||
>
|
||||
<IconRefresh className={appriseRescanPending ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
|
||||
{appriseRescanPending ? t("settings:checkingApprise") : t("settings:rescanApprise")}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t("settings:appriseDetectionServer")}</p>
|
||||
{appriseRescanError && (
|
||||
<p className="text-xs text-amber-600" role="alert" data-testid="apprise-rescan-error">
|
||||
{t("settings:appriseRescanFailed")}
|
||||
</p>
|
||||
)}
|
||||
{!appriseRescanPending && !appriseRescanError && appriseRescanResult !== null && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
role="status"
|
||||
data-testid="apprise-rescan-result"
|
||||
>
|
||||
{appriseRescanResult ? t("settings:appriseDetected") : t("settings:appriseNotDetected")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppriseProviderSectionBody({
|
||||
appriseAvailable,
|
||||
appriseProviders,
|
||||
baselineProviders,
|
||||
appriseFormMode,
|
||||
activeAppriseId,
|
||||
appriseName,
|
||||
appriseUrls,
|
||||
showAppriseForm,
|
||||
setAppriseName,
|
||||
setAppriseUrls,
|
||||
onAppriseNameEdit,
|
||||
onAppriseEdit,
|
||||
onOpenForm,
|
||||
onCloseForm,
|
||||
onCancelForm,
|
||||
onDeleteProvider,
|
||||
onTestProvider,
|
||||
}: Omit<
|
||||
ExternalProvidersSectionProps,
|
||||
| "notificationProvidersLoaded"
|
||||
| "notificationProvidersLoading"
|
||||
| "appriseRescanPending"
|
||||
| "appriseRescanError"
|
||||
| "appriseRescanResult"
|
||||
| "onRescanApprise"
|
||||
>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{!appriseAvailable && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* The link is part of the sentence, so the whole notice is one
|
||||
message and the <2> tag addresses the anchor by child index. */}
|
||||
<Trans i18nKey="settings:appriseNotInstalled">
|
||||
Apprise is not installed yet. You can add it later to enable remote notifications.{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://github.com/caronc/apprise?tab=readme-ov-file#installation"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View installation instructions
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
{appriseProviders.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t("settings:noAppriseProviders")}</p>
|
||||
)}
|
||||
<AppriseProviderList
|
||||
providers={appriseProviders}
|
||||
baselineProviders={baselineProviders}
|
||||
appriseFormMode={appriseFormMode}
|
||||
activeAppriseId={activeAppriseId}
|
||||
appriseName={appriseName}
|
||||
appriseUrls={appriseUrls}
|
||||
onNameChange={setAppriseName}
|
||||
onUrlsChange={setAppriseUrls}
|
||||
onAppriseNameEdit={onAppriseNameEdit}
|
||||
onAppriseEdit={onAppriseEdit}
|
||||
onOpenForm={onOpenForm}
|
||||
onCloseForm={onCloseForm}
|
||||
onCancelForm={onCancelForm}
|
||||
onDeleteProvider={onDeleteProvider}
|
||||
onTestProvider={onTestProvider}
|
||||
onTextareaInput={handleTextareaInput}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{appriseAvailable && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onOpenForm("create")}
|
||||
disabled={showAppriseForm}
|
||||
>
|
||||
{t("settings:addAppriseProvider")}
|
||||
</Button>
|
||||
)}
|
||||
{showAppriseForm && appriseFormMode === "create" && (
|
||||
<AppriseProviderForm
|
||||
mode="create"
|
||||
name={appriseName}
|
||||
urls={appriseUrls}
|
||||
onNameChange={setAppriseName}
|
||||
onUrlsChange={setAppriseUrls}
|
||||
onSubmit={onCloseForm}
|
||||
onCancel={onCancelForm}
|
||||
onInput={handleTextareaInput}
|
||||
formIsDirty
|
||||
nameIsDirty={appriseName.length > 0}
|
||||
urlsIsDirty={appriseUrls.length > 0}
|
||||
showSubmit={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalProvidersSection(props: ExternalProvidersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.notificationProviders} className="space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t("settings:externalProviders")}</div>
|
||||
<p className="text-xs text-muted-foreground" data-testid="external-providers-description">
|
||||
{t("settings:externalProvidersDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AppriseRescanControls
|
||||
notificationProvidersLoaded={props.notificationProvidersLoaded}
|
||||
notificationProvidersLoading={props.notificationProvidersLoading}
|
||||
appriseRescanPending={props.appriseRescanPending}
|
||||
appriseRescanError={props.appriseRescanError}
|
||||
appriseRescanResult={props.appriseRescanResult}
|
||||
onRescanApprise={props.onRescanApprise}
|
||||
/>
|
||||
</div>
|
||||
<AppriseProviderSectionBody {...props} />
|
||||
</SettingsTarget>
|
||||
);
|
||||
}
|
||||
|
||||
type AppriseProviderFormProps = {
|
||||
mode: AppriseFormMode;
|
||||
name: string;
|
||||
urls: string;
|
||||
onNameChange: (value: string) => void;
|
||||
onUrlsChange: (value: string) => void;
|
||||
onSubmit: () => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
onInput: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
nameIsDirty?: boolean;
|
||||
urlsIsDirty?: boolean;
|
||||
formIsDirty?: boolean;
|
||||
showSubmit?: boolean;
|
||||
};
|
||||
|
||||
function AppriseProviderForm({
|
||||
mode,
|
||||
name,
|
||||
urls,
|
||||
onNameChange,
|
||||
onUrlsChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onInput,
|
||||
nameIsDirty = false,
|
||||
urlsIsDirty = false,
|
||||
formIsDirty = nameIsDirty || urlsIsDirty,
|
||||
showSubmit = true,
|
||||
}: AppriseProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-dashed border-muted p-4 space-y-3"
|
||||
data-settings-dirty={formIsDirty}
|
||||
data-settings-dirty-level="container"
|
||||
>
|
||||
<div className="text-sm font-medium">{t("settings:appriseProvider")}</div>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
placeholder={t("settings:providerName")}
|
||||
data-settings-dirty={nameIsDirty}
|
||||
/>
|
||||
<Textarea
|
||||
value={urls}
|
||||
onChange={(event) => onUrlsChange(event.target.value)}
|
||||
onInput={onInput}
|
||||
placeholder={t("settings:appriseServiceUrls")}
|
||||
rows={1}
|
||||
className="min-h-0 h-auto"
|
||||
data-settings-dirty={urlsIsDirty}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{showSubmit && (
|
||||
<Button className="cursor-pointer" onClick={onSubmit}>
|
||||
{mode === "create" ? t("settings:addProvider") : t("settings:done")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" className="cursor-pointer" onClick={onCancel}>
|
||||
{t("settings:cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleTextareaInput(event: FormEvent<HTMLTextAreaElement>) {
|
||||
const t = event.currentTarget;
|
||||
t.style.height = "auto";
|
||||
t.style.height = `${t.scrollHeight}px`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { NotificationProvider } from "@/lib/types/http";
|
||||
import { NotificationsSettings } from "./notifications-settings";
|
||||
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
||||
testNotificationProvider: vi.fn(),
|
||||
updateNotificationProvider: vi.fn(),
|
||||
setNotificationProviders: vi.fn(),
|
||||
rescanApprise: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
@@ -23,13 +24,31 @@ vi.mock("@/lib/api", () => ({
|
||||
// compares them by reference, so a fresh array per render re-hydrates forever.
|
||||
const PROVIDERS: NotificationProvider[] = [];
|
||||
const EVENTS = ["session.turn_finished"];
|
||||
const APPRISE_PROVIDER: NotificationProvider = {
|
||||
id: "apprise-provider",
|
||||
name: "Saved Apprise",
|
||||
type: "apprise",
|
||||
config: { urls: ["json://saved"] },
|
||||
enabled: true,
|
||||
events: EVENTS,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
let appriseAvailable = false;
|
||||
let appriseRescanPending = false;
|
||||
let appriseRescanError = false;
|
||||
let appriseRescanResult: boolean | null = null;
|
||||
|
||||
vi.mock("@/hooks/domains/settings/use-notification-providers", () => ({
|
||||
useNotificationProviders: () => ({
|
||||
providers: PROVIDERS,
|
||||
events: EVENTS,
|
||||
appriseAvailable: false,
|
||||
appriseAvailable,
|
||||
loaded: true,
|
||||
rescanApprise: mocks.rescanApprise,
|
||||
appriseRescanPending,
|
||||
appriseRescanError,
|
||||
appriseRescanResult,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -45,7 +64,14 @@ vi.mock("@/lib/desktop/native-notification-client", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
PROVIDERS.length = 0;
|
||||
appriseAvailable = false;
|
||||
appriseRescanPending = false;
|
||||
appriseRescanError = false;
|
||||
appriseRescanResult = null;
|
||||
});
|
||||
|
||||
describe("NotificationsSettings", () => {
|
||||
it("renders the Apprise install notice as one sentence around its link", () => {
|
||||
@@ -92,4 +118,82 @@ describe("NotificationsSettings", () => {
|
||||
.filter((className) => /(?:^|\s)text-(?:base|lg|\d?xl)(?=[\s/]|$)/.test(className));
|
||||
expect(oversized).toEqual([]);
|
||||
});
|
||||
|
||||
it("rescans Apprise and exposes the fresh detection result", () => {
|
||||
const { rerender } = render(
|
||||
<SettingsSaveProvider>
|
||||
<NotificationsSettings />
|
||||
</SettingsSaveProvider>,
|
||||
);
|
||||
|
||||
const rescan = screen.getByRole("button", { name: "Rescan Apprise" });
|
||||
expect(rescan).toBeTruthy();
|
||||
expect(screen.getByText(/Detection runs on the Kandev server/)).toBeTruthy();
|
||||
|
||||
fireEvent.click(rescan);
|
||||
expect(mocks.rescanApprise).toHaveBeenCalledOnce();
|
||||
|
||||
appriseRescanPending = true;
|
||||
rerender(
|
||||
<SettingsSaveProvider>
|
||||
<NotificationsSettings />
|
||||
</SettingsSaveProvider>,
|
||||
);
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Checking Apprise" }) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
|
||||
appriseAvailable = true;
|
||||
appriseRescanPending = false;
|
||||
appriseRescanResult = true;
|
||||
rerender(
|
||||
<SettingsSaveProvider>
|
||||
<NotificationsSettings />
|
||||
</SettingsSaveProvider>,
|
||||
);
|
||||
expect(screen.getByText("Apprise detected.")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Add Apprise Provider" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the last availability and offers retry feedback after a rescan error", () => {
|
||||
const { rerender } = render(
|
||||
<SettingsSaveProvider>
|
||||
<NotificationsSettings />
|
||||
</SettingsSaveProvider>,
|
||||
);
|
||||
|
||||
appriseRescanError = true;
|
||||
rerender(
|
||||
<SettingsSaveProvider>
|
||||
<NotificationsSettings />
|
||||
</SettingsSaveProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Could not check Apprise. Try again.")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Rescan Apprise" })).toBeTruthy();
|
||||
expect(screen.getByText(/Apprise is not installed yet/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("routes edit Cancel through the draft rollback handler", () => {
|
||||
PROVIDERS.push(APPRISE_PROVIDER);
|
||||
appriseAvailable = true;
|
||||
render(
|
||||
<SettingsSaveProvider>
|
||||
<NotificationsSettings />
|
||||
</SettingsSaveProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
fireEvent.change(screen.getByDisplayValue("Saved Apprise"), {
|
||||
target: { value: "Changed Apprise" },
|
||||
});
|
||||
fireEvent.change(screen.getByDisplayValue("json://saved"), {
|
||||
target: { value: "json://changed" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(screen.getAllByText("Saved Apprise").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByDisplayValue("Changed Apprise")).toBeNull();
|
||||
expect(screen.queryByDisplayValue("json://changed")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { IconBell } from "@tabler/icons-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { NotificationSoundSection } from "@/components/settings/notification-sound-section";
|
||||
import { NotificationEventsTable } from "@/components/settings/notification-events-table";
|
||||
import { SettingsPageTemplate } from "@/components/settings/settings-page-template";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { DEFAULT_NOTIFICATION_EVENTS } from "@/lib/notifications/events";
|
||||
import type { NotificationProvider } from "@/lib/types/http";
|
||||
import {
|
||||
DesktopNotificationsSection,
|
||||
useNotificationPermission,
|
||||
@@ -23,274 +17,11 @@ import {
|
||||
useNotificationsActions,
|
||||
useIsDirty,
|
||||
type NotificationsState,
|
||||
type AppriseFormMode,
|
||||
} from "@/components/settings/notifications-settings-actions";
|
||||
import { ExternalProvidersSection } from "@/components/settings/notifications-settings-external-providers";
|
||||
import { SettingsTarget } from "@/components/settings/settings-target";
|
||||
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
|
||||
|
||||
function AppriseProviderCardActions({
|
||||
provider,
|
||||
onOpenForm,
|
||||
onDeleteProvider,
|
||||
onTestProvider,
|
||||
}: {
|
||||
provider: NotificationProvider;
|
||||
onOpenForm: (mode: AppriseFormMode, provider: NotificationProvider) => void;
|
||||
onDeleteProvider: (providerId: string) => void;
|
||||
onTestProvider: (providerId: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 cursor-pointer"
|
||||
aria-label={t("settings:sendTestNotificationFor", { name: provider.name })}
|
||||
onClick={() => void onTestProvider(provider.id)}
|
||||
>
|
||||
<IconBell className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings:sendTestNotification")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onOpenForm("edit", provider)}
|
||||
>
|
||||
{t("settings:edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onDeleteProvider(provider.id)}
|
||||
>
|
||||
{t("settings:remove")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppriseProviderList({
|
||||
providers,
|
||||
baselineProviders,
|
||||
appriseFormMode,
|
||||
activeAppriseId,
|
||||
appriseName,
|
||||
appriseUrls,
|
||||
onNameChange,
|
||||
onUrlsChange,
|
||||
onAppriseNameEdit,
|
||||
onAppriseEdit,
|
||||
onOpenForm,
|
||||
onCloseForm,
|
||||
onDeleteProvider,
|
||||
onTestProvider,
|
||||
onTextareaInput,
|
||||
}: {
|
||||
providers: NotificationProvider[];
|
||||
baselineProviders: NotificationProvider[];
|
||||
appriseFormMode: AppriseFormMode;
|
||||
activeAppriseId: string | null;
|
||||
appriseName: string;
|
||||
appriseUrls: string;
|
||||
onNameChange: (value: string) => void;
|
||||
onUrlsChange: (value: string) => void;
|
||||
onAppriseNameEdit: (providerId: string, value: string) => void;
|
||||
onAppriseEdit: (providerId: string, value: string) => void;
|
||||
onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void;
|
||||
onCloseForm: () => void;
|
||||
onCancelForm: () => void;
|
||||
onDeleteProvider: (providerId: string) => void;
|
||||
onTestProvider: (providerId: string) => Promise<void>;
|
||||
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{providers.map((provider) => {
|
||||
const isEditing = appriseFormMode === "edit" && activeAppriseId === provider.id;
|
||||
const baseline = baselineProviders.find((candidate) => candidate.id === provider.id);
|
||||
const nameIsDirty = isEditing && provider.name !== baseline?.name;
|
||||
const urlsIsDirty =
|
||||
isEditing &&
|
||||
JSON.stringify(provider.config?.urls ?? []) !==
|
||||
JSON.stringify(baseline?.config?.urls ?? []);
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="rounded-lg border border-muted p-4 space-y-3"
|
||||
data-settings-dirty={nameIsDirty || urlsIsDirty}
|
||||
data-settings-dirty-level="container"
|
||||
>
|
||||
{isEditing ? (
|
||||
<AppriseProviderForm
|
||||
mode="edit"
|
||||
name={appriseName}
|
||||
urls={appriseUrls}
|
||||
onNameChange={(value) => {
|
||||
onNameChange(value);
|
||||
onAppriseNameEdit(provider.id, value);
|
||||
}}
|
||||
onUrlsChange={(value) => {
|
||||
onUrlsChange(value);
|
||||
onAppriseEdit(provider.id, value);
|
||||
}}
|
||||
onSubmit={onCloseForm}
|
||||
onCancel={onCloseForm}
|
||||
onInput={onTextareaInput}
|
||||
nameIsDirty={nameIsDirty}
|
||||
urlsIsDirty={urlsIsDirty}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1 flex-1">
|
||||
<div className="font-medium">{provider.name}</div>
|
||||
{/* Product name — never translated (docs/i18n.md "Do not translate"). */}
|
||||
<div className="text-xs text-muted-foreground">Apprise</div>
|
||||
</div>
|
||||
<AppriseProviderCardActions
|
||||
provider={provider}
|
||||
onOpenForm={onOpenForm}
|
||||
onDeleteProvider={onDeleteProvider}
|
||||
onTestProvider={onTestProvider}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ExternalProvidersSectionProps = {
|
||||
appriseAvailable: boolean;
|
||||
appriseProviders: NotificationProvider[];
|
||||
baselineProviders: NotificationProvider[];
|
||||
appriseFormMode: AppriseFormMode;
|
||||
activeAppriseId: string | null;
|
||||
appriseName: string;
|
||||
appriseUrls: string;
|
||||
showAppriseForm: boolean;
|
||||
setAppriseName: (v: string) => void;
|
||||
setAppriseUrls: (v: string) => void;
|
||||
onAppriseNameEdit: (id: string, v: string) => void;
|
||||
onAppriseEdit: (id: string, v: string) => void;
|
||||
onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void;
|
||||
onCloseForm: () => void;
|
||||
onCancelForm: () => void;
|
||||
onDeleteProvider: (id: string) => void;
|
||||
onTestProvider: (id: string) => Promise<void>;
|
||||
onTextareaInput: (e: FormEvent<HTMLTextAreaElement>) => void;
|
||||
};
|
||||
|
||||
function ExternalProvidersSection({
|
||||
appriseAvailable,
|
||||
appriseProviders,
|
||||
baselineProviders,
|
||||
appriseFormMode,
|
||||
activeAppriseId,
|
||||
appriseName,
|
||||
appriseUrls,
|
||||
showAppriseForm,
|
||||
setAppriseName,
|
||||
setAppriseUrls,
|
||||
onAppriseNameEdit,
|
||||
onAppriseEdit,
|
||||
onOpenForm,
|
||||
onCloseForm,
|
||||
onCancelForm,
|
||||
onDeleteProvider,
|
||||
onTestProvider,
|
||||
onTextareaInput,
|
||||
}: ExternalProvidersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.notificationProviders} className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t("settings:externalProviders")}</div>
|
||||
<p className="text-xs text-muted-foreground" data-testid="external-providers-description">
|
||||
{t("settings:externalProvidersDescription")}
|
||||
</p>
|
||||
</div>
|
||||
{!appriseAvailable && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* The link is part of the sentence, so the whole notice is one
|
||||
message and the <2> tag addresses the anchor by child index. */}
|
||||
<Trans i18nKey="settings:appriseNotInstalled">
|
||||
Apprise is not installed yet. You can add it later to enable remote notifications.{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://github.com/caronc/apprise?tab=readme-ov-file#installation"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View installation instructions
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
{appriseProviders.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t("settings:noAppriseProviders")}</p>
|
||||
)}
|
||||
<AppriseProviderList
|
||||
providers={appriseProviders}
|
||||
baselineProviders={baselineProviders}
|
||||
appriseFormMode={appriseFormMode}
|
||||
activeAppriseId={activeAppriseId}
|
||||
appriseName={appriseName}
|
||||
appriseUrls={appriseUrls}
|
||||
onNameChange={setAppriseName}
|
||||
onUrlsChange={setAppriseUrls}
|
||||
onAppriseNameEdit={onAppriseNameEdit}
|
||||
onAppriseEdit={onAppriseEdit}
|
||||
onOpenForm={onOpenForm}
|
||||
onCloseForm={onCloseForm}
|
||||
onCancelForm={onCancelForm}
|
||||
onDeleteProvider={onDeleteProvider}
|
||||
onTestProvider={onTestProvider}
|
||||
onTextareaInput={onTextareaInput}
|
||||
/>
|
||||
{appriseAvailable && (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onOpenForm("create")}
|
||||
disabled={showAppriseForm}
|
||||
>
|
||||
{t("settings:addAppriseProvider")}
|
||||
</Button>
|
||||
{showAppriseForm && appriseFormMode === "create" && (
|
||||
<AppriseProviderForm
|
||||
mode="create"
|
||||
name={appriseName}
|
||||
urls={appriseUrls}
|
||||
onNameChange={setAppriseName}
|
||||
onUrlsChange={setAppriseUrls}
|
||||
onSubmit={onCloseForm}
|
||||
onCancel={onCancelForm}
|
||||
onInput={onTextareaInput}
|
||||
formIsDirty
|
||||
nameIsDirty={appriseName.length > 0}
|
||||
urlsIsDirty={appriseUrls.length > 0}
|
||||
showSubmit={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsTarget>
|
||||
);
|
||||
}
|
||||
|
||||
function useTableData(state: NotificationsState) {
|
||||
const { providers, notificationEvents } = state;
|
||||
const tableProviders = useMemo(
|
||||
@@ -360,6 +91,12 @@ export function NotificationsSettings() {
|
||||
providers,
|
||||
baselineProviders,
|
||||
appriseAvailable,
|
||||
loaded: notificationProvidersLoaded,
|
||||
loading: notificationProvidersLoading,
|
||||
rescanApprise,
|
||||
appriseRescanPending,
|
||||
appriseRescanError,
|
||||
appriseRescanResult,
|
||||
appriseName,
|
||||
setAppriseName,
|
||||
appriseUrls,
|
||||
@@ -400,6 +137,11 @@ export function NotificationsSettings() {
|
||||
appriseName={appriseName}
|
||||
appriseUrls={appriseUrls}
|
||||
showAppriseForm={showAppriseForm}
|
||||
notificationProvidersLoaded={notificationProvidersLoaded}
|
||||
notificationProvidersLoading={notificationProvidersLoading}
|
||||
appriseRescanPending={appriseRescanPending}
|
||||
appriseRescanError={appriseRescanError}
|
||||
appriseRescanResult={appriseRescanResult}
|
||||
setAppriseName={setAppriseName}
|
||||
setAppriseUrls={setAppriseUrls}
|
||||
onAppriseNameEdit={actions.handleAppriseNameEdit}
|
||||
@@ -409,7 +151,7 @@ export function NotificationsSettings() {
|
||||
onCancelForm={actions.cancelAppriseForm}
|
||||
onDeleteProvider={actions.handleDeleteProvider}
|
||||
onTestProvider={actions.handleTestProvider}
|
||||
onTextareaInput={handleTextareaInput}
|
||||
onRescanApprise={rescanApprise}
|
||||
/>
|
||||
<Separator className="my-4" />
|
||||
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.notificationEvents} className="space-y-4">
|
||||
@@ -432,75 +174,3 @@ export function NotificationsSettings() {
|
||||
</SettingsPageTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
type AppriseProviderFormProps = {
|
||||
mode: AppriseFormMode;
|
||||
name: string;
|
||||
urls: string;
|
||||
onNameChange: (value: string) => void;
|
||||
onUrlsChange: (value: string) => void;
|
||||
onSubmit: () => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
onInput: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
nameIsDirty?: boolean;
|
||||
urlsIsDirty?: boolean;
|
||||
formIsDirty?: boolean;
|
||||
showSubmit?: boolean;
|
||||
};
|
||||
|
||||
function AppriseProviderForm({
|
||||
mode,
|
||||
name,
|
||||
urls,
|
||||
onNameChange,
|
||||
onUrlsChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onInput,
|
||||
nameIsDirty = false,
|
||||
urlsIsDirty = false,
|
||||
formIsDirty = nameIsDirty || urlsIsDirty,
|
||||
showSubmit = true,
|
||||
}: AppriseProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-dashed border-muted p-4 space-y-3"
|
||||
data-settings-dirty={formIsDirty}
|
||||
data-settings-dirty-level="container"
|
||||
>
|
||||
<div className="text-sm font-medium">{t("settings:appriseProvider")}</div>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
placeholder={t("settings:providerName")}
|
||||
data-settings-dirty={nameIsDirty}
|
||||
/>
|
||||
<Textarea
|
||||
value={urls}
|
||||
onChange={(event) => onUrlsChange(event.target.value)}
|
||||
onInput={onInput}
|
||||
placeholder={t("settings:appriseServiceUrls")}
|
||||
rows={1}
|
||||
className="min-h-0 h-auto"
|
||||
data-settings-dirty={urlsIsDirty}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{showSubmit && (
|
||||
<Button className="cursor-pointer" onClick={onSubmit}>
|
||||
{mode === "create" ? t("settings:addProvider") : t("settings:done")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" className="cursor-pointer" onClick={onCancel}>
|
||||
{t("settings:cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleTextareaInput(event: FormEvent<HTMLTextAreaElement>) {
|
||||
const t = event.currentTarget;
|
||||
t.style.height = "auto";
|
||||
t.style.height = `${t.scrollHeight}px`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
export const APPRISE_EVENT = "session.turn_finished";
|
||||
export const APPRISE_PROVIDER_NAME = "E2E local notifications";
|
||||
|
||||
type RescanOutcome = boolean | "error";
|
||||
|
||||
function providerResponse(appriseAvailable: boolean) {
|
||||
return {
|
||||
providers: [
|
||||
{
|
||||
id: "e2e-local-notification-provider",
|
||||
name: APPRISE_PROVIDER_NAME,
|
||||
type: "local",
|
||||
config: {},
|
||||
enabled: true,
|
||||
events: [],
|
||||
created_at: "2026-09-09T00:00:00Z",
|
||||
updated_at: "2026-09-09T00:00:00Z",
|
||||
},
|
||||
],
|
||||
apprise_available: appriseAvailable,
|
||||
events: [APPRISE_EVENT],
|
||||
};
|
||||
}
|
||||
|
||||
export async function routeAppriseRescans(
|
||||
page: Page,
|
||||
outcomes: RescanOutcome[],
|
||||
): Promise<{ startRescan: () => void }> {
|
||||
let rescanStarted = false;
|
||||
let outcomeIndex = 0;
|
||||
await page.route("**/api/v1/notification-providers**", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
if (!rescanStarted) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
json: providerResponse(false),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = outcomes[Math.min(outcomeIndex++, Math.max(outcomes.length - 1, 0))] ?? false;
|
||||
if (outcome === "error") {
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: "application/json",
|
||||
json: { error: "provider list unavailable" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
json: providerResponse(outcome),
|
||||
});
|
||||
});
|
||||
return {
|
||||
startRescan: () => {
|
||||
rescanStarted = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { APPRISE_PROVIDER_NAME, routeAppriseRescans } from "./apprise-rescan-helpers";
|
||||
|
||||
test.describe("Apprise rescan", () => {
|
||||
test("refreshes availability, preserves an event draft, and supports retry", async ({
|
||||
testPage,
|
||||
prCapture,
|
||||
}) => {
|
||||
const { startRescan } = await routeAppriseRescans(testPage, [true, "error", false]);
|
||||
await testPage.goto("/settings/preferences/notifications");
|
||||
|
||||
const rescan = testPage.getByTestId("apprise-rescan");
|
||||
const event = testPage.getByRole("checkbox", {
|
||||
name: `Agent turn finished for ${APPRISE_PROVIDER_NAME}`,
|
||||
});
|
||||
await expect(testPage.getByTestId("notification-events-desktop-table")).toBeVisible();
|
||||
await expect(rescan).toBeVisible();
|
||||
await expect(testPage.getByRole("button", { name: "Add Apprise Provider" })).toHaveCount(0);
|
||||
await expect(event).not.toBeChecked();
|
||||
|
||||
await event.click();
|
||||
await expect(event).toBeChecked();
|
||||
await expect(testPage.getByRole("button", { name: "Save changes" })).toBeVisible();
|
||||
|
||||
startRescan();
|
||||
await rescan.click();
|
||||
await expect(testPage.getByText("Apprise detected.", { exact: true })).toBeVisible();
|
||||
await expect(testPage.getByRole("button", { name: "Add Apprise Provider" })).toBeVisible();
|
||||
await expect(event).toBeChecked();
|
||||
await expect(testPage.getByRole("button", { name: "Save changes" })).toBeVisible();
|
||||
await prCapture.screenshot("apprise-rescan-desktop", {
|
||||
caption: "Desktop Apprise detection and preserved notification draft",
|
||||
});
|
||||
|
||||
await rescan.click();
|
||||
await expect(testPage.getByTestId("apprise-rescan-error")).toHaveText(
|
||||
"Could not check Apprise. Try again.",
|
||||
);
|
||||
await expect(testPage.getByRole("button", { name: "Add Apprise Provider" })).toBeVisible();
|
||||
|
||||
await rescan.click();
|
||||
await expect(testPage.getByText("Apprise was not detected.", { exact: true })).toBeVisible();
|
||||
await expect(testPage.getByRole("button", { name: "Add Apprise Provider" })).toHaveCount(0);
|
||||
await expect(event).toBeChecked();
|
||||
expect(
|
||||
await testPage.evaluate(() => document.documentElement.scrollWidth > window.innerWidth),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { APPRISE_PROVIDER_NAME, routeAppriseRescans } from "./apprise-rescan-helpers";
|
||||
|
||||
test.describe("Mobile Apprise rescan", () => {
|
||||
test("detects Apprise with a touch-sized action and no horizontal overflow", async ({
|
||||
testPage,
|
||||
prCapture,
|
||||
}) => {
|
||||
const { startRescan } = await routeAppriseRescans(testPage, [true]);
|
||||
await testPage.goto("/settings/preferences/notifications");
|
||||
|
||||
const rescan = testPage.getByTestId("apprise-rescan");
|
||||
await expect(rescan).toBeVisible();
|
||||
const beforeBox = await rescan.boundingBox();
|
||||
expect(beforeBox).not.toBeNull();
|
||||
expect(beforeBox!.height).toBeGreaterThanOrEqual(44);
|
||||
|
||||
startRescan();
|
||||
await rescan.tap();
|
||||
await expect(testPage.getByText("Apprise detected.", { exact: true })).toBeVisible();
|
||||
await expect(testPage.getByRole("button", { name: "Add Apprise Provider" })).toBeVisible();
|
||||
await expect(
|
||||
testPage.getByRole("checkbox", { name: `Agent turn finished for ${APPRISE_PROVIDER_NAME}` }),
|
||||
).toBeVisible();
|
||||
await prCapture.screenshot("apprise-rescan-mobile", {
|
||||
caption: "Mobile Apprise detection with a touch-sized rescan action",
|
||||
});
|
||||
|
||||
const afterBox = await rescan.boundingBox();
|
||||
expect(afterBox).not.toBeNull();
|
||||
expect(afterBox!.height).toBeGreaterThanOrEqual(44);
|
||||
expect(
|
||||
await testPage.evaluate(() => document.documentElement.scrollWidth > window.innerWidth),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps the rescan action touch-sized on a coarse tablet", async ({ tabletTestPage }) => {
|
||||
await routeAppriseRescans(tabletTestPage, [true]);
|
||||
await tabletTestPage.goto("/settings/preferences/notifications");
|
||||
|
||||
await expect.poll(() => tabletTestPage.evaluate(() => window.innerWidth)).toBe(900);
|
||||
await expect
|
||||
.poll(() => tabletTestPage.evaluate(() => matchMedia("(pointer: coarse)").matches))
|
||||
.toBe(true);
|
||||
|
||||
const rescan = tabletTestPage.getByTestId("apprise-rescan");
|
||||
await expect(rescan).toBeVisible();
|
||||
const box = await rescan.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useNotificationProviders } from "./use-notification-providers";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listNotificationProviders: vi.fn(),
|
||||
setAppriseAvailable: vi.fn(),
|
||||
setNotificationProviders: vi.fn(),
|
||||
setNotificationProvidersLoading: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
listNotificationProviders: mocks.listNotificationProviders,
|
||||
}));
|
||||
|
||||
const storeState = {
|
||||
notificationProviders: {
|
||||
items: [],
|
||||
events: [],
|
||||
appriseAvailable: false,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
...storeState,
|
||||
setAppriseAvailable: mocks.setAppriseAvailable,
|
||||
setNotificationProviders: mocks.setNotificationProviders,
|
||||
setNotificationProvidersLoading: mocks.setNotificationProvidersLoading,
|
||||
}),
|
||||
}));
|
||||
|
||||
function providerResponse(appriseAvailable: boolean) {
|
||||
return {
|
||||
providers: [],
|
||||
events: [],
|
||||
apprise_available: appriseAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
storeState.notificationProviders.appriseAvailable = false;
|
||||
mocks.listNotificationProviders.mockReset();
|
||||
});
|
||||
|
||||
async function assertStaleRescanAfterUnmountIsIgnored() {
|
||||
let resolveA: (value: ReturnType<typeof providerResponse>) => void = () => undefined;
|
||||
let resolveB: (value: ReturnType<typeof providerResponse>) => void = () => undefined;
|
||||
const requestA = new Promise<ReturnType<typeof providerResponse>>((resolve) => {
|
||||
resolveA = resolve;
|
||||
});
|
||||
const requestB = new Promise<ReturnType<typeof providerResponse>>((resolve) => {
|
||||
resolveB = resolve;
|
||||
});
|
||||
mocks.listNotificationProviders.mockReturnValueOnce(requestA).mockReturnValueOnce(requestB);
|
||||
|
||||
const first = renderHook(() => useNotificationProviders());
|
||||
act(() => {
|
||||
void first.result.current.rescanApprise();
|
||||
});
|
||||
await waitFor(() => expect(first.result.current.appriseRescanPending).toBe(true));
|
||||
|
||||
first.unmount();
|
||||
const second = renderHook(() => useNotificationProviders());
|
||||
act(() => {
|
||||
void second.result.current.rescanApprise();
|
||||
});
|
||||
await waitFor(() => expect(mocks.listNotificationProviders).toHaveBeenCalledTimes(2));
|
||||
|
||||
await act(async () => {
|
||||
resolveB(providerResponse(true));
|
||||
await requestB;
|
||||
});
|
||||
expect(mocks.setAppriseAvailable).toHaveBeenCalledWith(true);
|
||||
expect(second.result.current.appriseRescanResult).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveA(providerResponse(false));
|
||||
await requestA;
|
||||
});
|
||||
expect(mocks.setAppriseAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.setAppriseAvailable).toHaveBeenLastCalledWith(true);
|
||||
expect(second.result.current.appriseRescanResult).toBe(true);
|
||||
}
|
||||
|
||||
describe("useNotificationProviders Apprise rescan", () => {
|
||||
it.each([
|
||||
[false, true],
|
||||
[true, false],
|
||||
])("updates availability from %s to %s", async (initial, next) => {
|
||||
storeState.notificationProviders.appriseAvailable = initial;
|
||||
mocks.listNotificationProviders.mockResolvedValueOnce(providerResponse(next));
|
||||
|
||||
const { result } = renderHook(() => useNotificationProviders());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.rescanApprise();
|
||||
});
|
||||
|
||||
expect(mocks.listNotificationProviders).toHaveBeenCalledWith({ cache: "no-store" });
|
||||
expect(mocks.setAppriseAvailable).toHaveBeenCalledWith(next);
|
||||
expect(result.current.appriseRescanResult).toBe(next);
|
||||
expect(result.current.appriseRescanError).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks duplicate requests while the first request is pending", async () => {
|
||||
let resolveRequest: (value: ReturnType<typeof providerResponse>) => void = () => undefined;
|
||||
const request = new Promise<ReturnType<typeof providerResponse>>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
mocks.listNotificationProviders.mockReturnValueOnce(request);
|
||||
|
||||
const { result } = renderHook(() => useNotificationProviders());
|
||||
|
||||
act(() => {
|
||||
void result.current.rescanApprise();
|
||||
void result.current.rescanApprise();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.appriseRescanPending).toBe(true));
|
||||
expect(mocks.listNotificationProviders).toHaveBeenCalledOnce();
|
||||
|
||||
await act(async () => {
|
||||
resolveRequest(providerResponse(true));
|
||||
await request;
|
||||
});
|
||||
await waitFor(() => expect(result.current.appriseRescanPending).toBe(false));
|
||||
});
|
||||
|
||||
it("retains the last availability after failure and retries successfully", async () => {
|
||||
storeState.notificationProviders.appriseAvailable = true;
|
||||
mocks.listNotificationProviders
|
||||
.mockRejectedValueOnce(new Error("backend unavailable"))
|
||||
.mockResolvedValueOnce(providerResponse(false));
|
||||
|
||||
const { result } = renderHook(() => useNotificationProviders());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.rescanApprise();
|
||||
});
|
||||
|
||||
expect(result.current.appriseRescanError).toBe(true);
|
||||
expect(result.current.appriseRescanResult).toBeNull();
|
||||
expect(mocks.setAppriseAvailable).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.rescanApprise();
|
||||
});
|
||||
|
||||
expect(mocks.setAppriseAvailable).toHaveBeenCalledWith(false);
|
||||
expect(result.current.appriseRescanError).toBe(false);
|
||||
expect(result.current.appriseRescanResult).toBe(false);
|
||||
});
|
||||
|
||||
it("updates only availability when a delayed response resolves", async () => {
|
||||
let resolveRequest: (value: ReturnType<typeof providerResponse>) => void = () => undefined;
|
||||
const request = new Promise<ReturnType<typeof providerResponse>>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
mocks.listNotificationProviders.mockReturnValueOnce(request);
|
||||
|
||||
const { result } = renderHook(() => useNotificationProviders());
|
||||
|
||||
act(() => {
|
||||
void result.current.rescanApprise();
|
||||
});
|
||||
await waitFor(() => expect(result.current.appriseRescanPending).toBe(true));
|
||||
|
||||
await act(async () => {
|
||||
resolveRequest(providerResponse(true));
|
||||
await request;
|
||||
});
|
||||
|
||||
expect(mocks.setAppriseAvailable).toHaveBeenCalledWith(true);
|
||||
expect(mocks.setNotificationProviders).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(
|
||||
"ignores a stale rescan after the owning hook unmounts",
|
||||
assertStaleRescanAfterUnmountIsIgnored,
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listNotificationProviders } from "@/lib/api";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
|
||||
@@ -14,6 +14,19 @@ export function useNotificationProviders() {
|
||||
const setNotificationProvidersLoading = useAppStore(
|
||||
(state) => state.setNotificationProvidersLoading,
|
||||
);
|
||||
const setAppriseAvailable = useAppStore((state) => state.setAppriseAvailable);
|
||||
const [appriseRescanPending, setAppriseRescanPending] = useState(false);
|
||||
const [appriseRescanError, setAppriseRescanError] = useState(false);
|
||||
const [appriseRescanResult, setAppriseRescanResult] = useState<boolean | null>(null);
|
||||
const appriseRescanInFlight = useRef(false);
|
||||
const appriseRescanRequestId = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// A delayed response must not publish into a later settings-page instance.
|
||||
appriseRescanRequestId.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded || loading) return;
|
||||
@@ -42,11 +55,37 @@ export function useNotificationProviders() {
|
||||
});
|
||||
}, [loaded, loading, setNotificationProviders, setNotificationProvidersLoading]);
|
||||
|
||||
const rescanApprise = useCallback(async () => {
|
||||
if (loading || appriseRescanInFlight.current) return;
|
||||
const requestId = ++appriseRescanRequestId.current;
|
||||
appriseRescanInFlight.current = true;
|
||||
setAppriseRescanPending(true);
|
||||
setAppriseRescanError(false);
|
||||
try {
|
||||
const response = await listNotificationProviders({ cache: "no-store" });
|
||||
if (requestId !== appriseRescanRequestId.current) return;
|
||||
const available = response.apprise_available ?? false;
|
||||
setAppriseAvailable(available);
|
||||
setAppriseRescanResult(available);
|
||||
} catch {
|
||||
if (requestId !== appriseRescanRequestId.current) return;
|
||||
setAppriseRescanError(true);
|
||||
} finally {
|
||||
if (requestId !== appriseRescanRequestId.current) return;
|
||||
appriseRescanInFlight.current = false;
|
||||
setAppriseRescanPending(false);
|
||||
}
|
||||
}, [loading, setAppriseAvailable]);
|
||||
|
||||
return {
|
||||
providers,
|
||||
events,
|
||||
appriseAvailable,
|
||||
loaded,
|
||||
loading,
|
||||
rescanApprise,
|
||||
appriseRescanPending,
|
||||
appriseRescanError,
|
||||
appriseRescanResult,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ import type { AgentRuntimeAvailability } from "@/lib/types/agent-runtime";
|
||||
import type { AgentProfileRecentUseContext } from "@/lib/types/http-agent-profile-recent-use";
|
||||
import type { UISliceActions as UIA } from "./slices/ui/types";
|
||||
import type * as UISliceTypes from "./slices/ui/types";
|
||||
import type { AgentUpdateJob, InstallJob } from "./slices/settings/types";
|
||||
import type {
|
||||
AgentUpdateJob,
|
||||
InstallJob,
|
||||
NotificationProvidersUpdate,
|
||||
} from "./slices/settings/types";
|
||||
import {
|
||||
defaultWorkspaceState,
|
||||
defaultSettingsState,
|
||||
@@ -47,7 +51,6 @@ import type {
|
||||
EditorsState,
|
||||
PromptsState,
|
||||
SecretsState,
|
||||
NotificationProvidersState,
|
||||
SettingsDataState,
|
||||
SleepInhibitionStoreState,
|
||||
UserSettingsState,
|
||||
@@ -313,7 +316,8 @@ export type AppState = KanbanSlice & {
|
||||
setSpritesInstances: (instances: import("@/lib/types/http-sprites").SpritesInstance[]) => void;
|
||||
setSpritesLoading: (loading: boolean) => void;
|
||||
removeSpritesInstance: (name: string) => void;
|
||||
setNotificationProviders: (state: NotificationProvidersState) => void;
|
||||
setNotificationProviders: (state: NotificationProvidersUpdate) => void;
|
||||
setAppriseAvailable: (available: boolean) => void;
|
||||
setNotificationProvidersLoading: (loading: boolean) => void;
|
||||
setSleepInhibition: (response: NonNullable<SleepInhibitionStoreState["response"]>) => void;
|
||||
setSleepInhibitionLoading: (loading: boolean) => void;
|
||||
|
||||
@@ -52,6 +52,38 @@ describe("user settings snapshots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification provider availability", () => {
|
||||
it("changes only Apprise availability", () => {
|
||||
const store = makeStore();
|
||||
const before = store.getState().notificationProviders;
|
||||
|
||||
// @covers AC-PLATFORM-APPRISE-RESCAN-001.4
|
||||
store.getState().setAppriseAvailable(true);
|
||||
|
||||
const after = store.getState().notificationProviders;
|
||||
expect(after.appriseAvailable).toBe(true);
|
||||
expect(after.items).toBe(before.items);
|
||||
expect(after.events).toBe(before.events);
|
||||
expect(after.loaded).toBe(before.loaded);
|
||||
expect(after.loading).toBe(before.loading);
|
||||
});
|
||||
|
||||
it("preserves availability when a provider update omits it", () => {
|
||||
const store = makeStore();
|
||||
store.getState().setAppriseAvailable(true);
|
||||
const before = store.getState().notificationProviders;
|
||||
|
||||
store.getState().setNotificationProviders({
|
||||
items: before.items,
|
||||
events: before.events,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
expect(store.getState().notificationProviders.appriseAvailable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings update jobs", () => {
|
||||
it("rehydrates the newest retained job for each agent", () => {
|
||||
const store = makeStore();
|
||||
|
||||
@@ -349,6 +349,7 @@ function createSecretAndSpriteActions(
|
||||
| "setSpritesLoading"
|
||||
| "removeSpritesInstance"
|
||||
| "setNotificationProviders"
|
||||
| "setAppriseAvailable"
|
||||
| "setNotificationProvidersLoading"
|
||||
> {
|
||||
return {
|
||||
@@ -399,10 +400,16 @@ function createSecretAndSpriteActions(
|
||||
set((draft) => {
|
||||
draft.notificationProviders.items = state.items;
|
||||
draft.notificationProviders.events = state.events;
|
||||
draft.notificationProviders.appriseAvailable = state.appriseAvailable;
|
||||
if (state.appriseAvailable !== undefined) {
|
||||
draft.notificationProviders.appriseAvailable = state.appriseAvailable;
|
||||
}
|
||||
draft.notificationProviders.loaded = state.loaded;
|
||||
draft.notificationProviders.loading = state.loading;
|
||||
}),
|
||||
setAppriseAvailable: (available) =>
|
||||
set((draft) => {
|
||||
draft.notificationProviders.appriseAvailable = available;
|
||||
}),
|
||||
setNotificationProvidersLoading: (loading) =>
|
||||
set((draft) => {
|
||||
draft.notificationProviders.loading = loading;
|
||||
|
||||
@@ -382,6 +382,10 @@ export type NotificationProvidersState = {
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export type NotificationProvidersUpdate = Omit<NotificationProvidersState, "appriseAvailable"> & {
|
||||
appriseAvailable?: boolean;
|
||||
};
|
||||
|
||||
export type SettingsDataState = {
|
||||
executorsLoaded: boolean;
|
||||
agentsLoaded: boolean;
|
||||
@@ -528,7 +532,8 @@ export type SettingsSliceActions = {
|
||||
setSpritesInstances: (instances: SpritesInstance[]) => void;
|
||||
setSpritesLoading: (loading: boolean) => void;
|
||||
removeSpritesInstance: (name: string) => void;
|
||||
setNotificationProviders: (state: NotificationProvidersState) => void;
|
||||
setNotificationProviders: (state: NotificationProvidersUpdate) => void;
|
||||
setAppriseAvailable: (available: boolean) => void;
|
||||
setNotificationProvidersLoading: (loading: boolean) => void;
|
||||
setSettingsData: (next: Partial<SettingsDataState>) => void;
|
||||
setSleepInhibition: (response: SleepInhibitionResponse) => void;
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
"appriseProvider": "Apprise Provider",
|
||||
"appriseServiceUrls": "Service URL(s)",
|
||||
"appriseUrlRequired": "At least one Apprise service URL is required.",
|
||||
"appriseDetected": "Apprise detected.",
|
||||
"appriseDetectionServer": "Detection runs on the Kandev server.",
|
||||
"appriseNotDetected": "Apprise was not detected.",
|
||||
"appriseRescanFailed": "Could not check Apprise. Try again.",
|
||||
"rescanApprise": "Rescan Apprise",
|
||||
"archiveConfirmation": "Archive Confirmation",
|
||||
"preventAutoStartAgentOnOpen": "Prevent auto-start on open",
|
||||
"preventAutoStartAgentOnOpenHelp": "Don't start the agent automatically when opening a task after a restart or from the final workflow step. The Start agent button is shown instead.",
|
||||
@@ -129,6 +134,7 @@
|
||||
"deleteSecret": "Delete secret",
|
||||
"cannotDeleteSecret": "Cannot delete secret",
|
||||
"checkingSecretReferences": "Checking where this secret is used…",
|
||||
"checkingApprise": "Checking Apprise",
|
||||
"secretReferencesLoadFailed": "Couldn't check where this secret is used",
|
||||
"secretDeleteFailed": "Couldn't delete secret",
|
||||
"secretInUse": "This secret is in use by: {{references}}. Remove or replace these references before deleting it.",
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
"appriseProvider": "Àƥƥŕĩśē Ƥŕōvĩďēŕ",
|
||||
"appriseServiceUrls": "Śēŕvĩćē ŨŔĹ(ś)",
|
||||
"appriseUrlRequired": "Àţ ĺēàśţ ōńē Àƥƥŕĩśē śēŕvĩćē ŨŔĹ ĩś ŕēqũĩŕēď.",
|
||||
"appriseDetected": "Àƥƥŕĩśē ďēţēćţēď.",
|
||||
"appriseDetectionServer": "Ďēţēćţĩōń ŕũńś ōń ţĥē Ķàńďēv śēŕvēŕ.",
|
||||
"appriseNotDetected": "Àƥƥŕĩśē ŵàś ńōţ ďēţēćţēď.",
|
||||
"appriseRescanFailed": "Ćōũĺď ńōţ ćĥēćķ Àƥƥŕĩśē. Ţŕŷ àĝàĩń.",
|
||||
"rescanApprise": "Ŕēśćàń Àƥƥŕĩśē",
|
||||
"archiveConfirmation": "Àŕćĥĩvē Ćōńƒĩŕḿàţĩōń",
|
||||
"preventAutoStartAgentOnOpen": "Ƥŕēvēńţ àũţō-śţàŕţ ōń ōƥēń",
|
||||
"preventAutoStartAgentOnOpenHelp": "Ďōń'ţ śţàŕţ ţĥē àĝēńţ àũţōḿàţĩćàĺĺŷ ŵĥēń ōƥēńĩńĝ à ţàśķ àƒţēŕ à ŕēśţàŕţ ōŕ ƒŕōḿ ţĥē ƒĩńàĺ ŵōŕķƒĺōŵ śţēƥ. Ţĥē Śţàŕţ àĝēńţ ƀũţţōń ĩś śĥōŵń ĩńśţēàď.",
|
||||
@@ -129,6 +134,7 @@
|
||||
"deleteSecret": "Ďēĺēţē śēćŕēţ",
|
||||
"cannotDeleteSecret": "Ćàńńōţ ďēĺēţē śēćŕēţ",
|
||||
"checkingSecretReferences": "Ćĥēćķĩńĝ ŵĥēŕē ţĥĩś śēćŕēţ ĩś ũśēď…",
|
||||
"checkingApprise": "Ćĥēćķĩńĝ Àƥƥŕĩśē",
|
||||
"secretReferencesLoadFailed": "Ćōũĺďń'ţ ćĥēćķ ŵĥēŕē ţĥĩś śēćŕēţ ĩś ũśēď",
|
||||
"secretDeleteFailed": "Ćōũĺďń'ţ ďēĺēţē śēćŕēţ",
|
||||
"secretInUse": "Ţĥĩś śēćŕēţ ĩś ĩń ũśē ƀŷ: {{references}}. Ŕēḿōvē ōŕ ŕēƥĺàćē ţĥēśē ŕēƒēŕēńćēś ƀēƒōŕē ďēĺēţĩńĝ ĩţ.",
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
"appriseProvider": "Fornecedor Apprise",
|
||||
"appriseServiceUrls": "URL(s) do serviço",
|
||||
"appriseUrlRequired": "É obrigatório pelo menos um URL de serviço Apprise.",
|
||||
"appriseDetected": "Apprise detetado.",
|
||||
"appriseDetectionServer": "A deteção é executada no servidor Kandev.",
|
||||
"appriseNotDetected": "O Apprise não foi detetado.",
|
||||
"appriseRescanFailed": "Não foi possível verificar o Apprise. Tente novamente.",
|
||||
"rescanApprise": "Procurar o Apprise novamente",
|
||||
"archiveConfirmation": "Confirmação de arquivo",
|
||||
"preventAutoStartAgentOnOpen": "Impedir arranque automático ao abrir",
|
||||
"preventAutoStartAgentOnOpenHelp": "Não inicia o agente automaticamente ao abrir uma tarefa após um reinício ou a partir do passo final do fluxo de trabalho. O botão Iniciar o agente é mostrado em vez disso.",
|
||||
@@ -126,6 +131,7 @@
|
||||
"deleteSecret": "Eliminar segredo",
|
||||
"cannotDeleteSecret": "Não é possível eliminar o segredo",
|
||||
"checkingSecretReferences": "A verificar onde este segredo é utilizado…",
|
||||
"checkingApprise": "A verificar o Apprise",
|
||||
"secretReferencesLoadFailed": "Não foi possível verificar onde este segredo é utilizado",
|
||||
"secretDeleteFailed": "Não foi possível eliminar o segredo",
|
||||
"secretInUse": "Este segredo está em uso por: {{references}}. Remova ou substitua estas referências antes de o eliminar.",
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
"appriseProvider": "Apprise 提供商",
|
||||
"appriseServiceUrls": "服务 URL",
|
||||
"appriseUrlRequired": "至少需要一个 Apprise 服务 URL。",
|
||||
"appriseDetected": "已检测到 Apprise。",
|
||||
"appriseDetectionServer": "检测在 Kandev 服务器上运行。",
|
||||
"appriseNotDetected": "未检测到 Apprise。",
|
||||
"appriseRescanFailed": "无法检查 Apprise。请重试。",
|
||||
"rescanApprise": "重新检测 Apprise",
|
||||
"archiveConfirmation": "归档确认",
|
||||
"preventAutoStartAgentOnOpen": "打开任务时不自动启动智能体",
|
||||
"preventAutoStartAgentOnOpenHelp": "重启后,或从工作流最后一步打开任务时,不会自动启动智能体,改为显示“启动智能体”按钮。",
|
||||
@@ -129,6 +134,7 @@
|
||||
"deleteSecret": "删除密钥",
|
||||
"cannotDeleteSecret": "无法删除密钥",
|
||||
"checkingSecretReferences": "正在检查此密钥的使用位置…",
|
||||
"checkingApprise": "正在检查 Apprise",
|
||||
"secretReferencesLoadFailed": "无法检查此密钥的使用位置",
|
||||
"secretDeleteFailed": "无法删除密钥",
|
||||
"secretInUse": "以下位置正在使用此密钥:{{references}}。删除密钥前,请移除或替换这些引用。",
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
"appriseProvider": "Apprise 提供者",
|
||||
"appriseServiceUrls": "服務 URL",
|
||||
"appriseUrlRequired": "至少需要一個 Apprise 服務 URL。",
|
||||
"appriseDetected": "已檢測到 Apprise。",
|
||||
"appriseDetectionServer": "檢測在 Kandev 伺服器上執行。",
|
||||
"appriseNotDetected": "未檢測到 Apprise。",
|
||||
"appriseRescanFailed": "無法檢查 Apprise。請重試。",
|
||||
"rescanApprise": "重新檢測 Apprise",
|
||||
"archiveConfirmation": "歸檔確認",
|
||||
"preventAutoStartAgentOnOpen": "開啟任務時不自動啓動代理程式",
|
||||
"preventAutoStartAgentOnOpenHelp": "重新啓動後,或從工作流程最後一步開啟任務時,不會自動啓動代理程式,改為顯示「啓動代理程式」按鈕。",
|
||||
@@ -129,6 +134,7 @@
|
||||
"deleteSecret": "刪除密鑰",
|
||||
"cannotDeleteSecret": "無法刪除密鑰",
|
||||
"checkingSecretReferences": "正在檢查此密鑰的使用位置…",
|
||||
"checkingApprise": "正在檢查 Apprise",
|
||||
"secretReferencesLoadFailed": "無法檢查此密鑰的使用位置",
|
||||
"secretDeleteFailed": "無法刪除密鑰",
|
||||
"secretInUse": "以下位置正在使用此密鑰:{{references}}。刪除密鑰前,請移除或替換這些引用。",
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
"appriseProvider": "Apprise 提供者",
|
||||
"appriseServiceUrls": "服務 URL",
|
||||
"appriseUrlRequired": "至少需要一個 Apprise 服務 URL。",
|
||||
"appriseDetected": "已檢測到 Apprise。",
|
||||
"appriseDetectionServer": "檢測在 Kandev 伺服器上執行。",
|
||||
"appriseNotDetected": "未檢測到 Apprise。",
|
||||
"appriseRescanFailed": "無法檢查 Apprise。請重試。",
|
||||
"rescanApprise": "重新檢測 Apprise",
|
||||
"archiveConfirmation": "歸檔確認",
|
||||
"preventAutoStartAgentOnOpen": "開啟任務時不自動啟動代理程式",
|
||||
"preventAutoStartAgentOnOpenHelp": "重新啟動後,或從工作流程最後一步開啟任務時,不會自動啟動代理程式,改為顯示「啟動代理程式」按鈕。",
|
||||
@@ -129,6 +134,7 @@
|
||||
"deleteSecret": "刪除金鑰",
|
||||
"cannotDeleteSecret": "無法刪除金鑰",
|
||||
"checkingSecretReferences": "正在檢查此金鑰的使用位置…",
|
||||
"checkingApprise": "正在檢查 Apprise",
|
||||
"secretReferencesLoadFailed": "無法檢查此金鑰的使用位置",
|
||||
"secretDeleteFailed": "無法刪除金鑰",
|
||||
"secretInUse": "以下位置正在使用此金鑰:{{references}}。刪除金鑰前,請移除或替換這些引用。",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
created: 2026-09-09
|
||||
status: done
|
||||
requirements:
|
||||
- REQ-PLATFORM-APPRISE-RESCAN-001
|
||||
system_design:
|
||||
- ../../specs/platform/system-design/apprise-rescan.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Apprise Rescan
|
||||
|
||||
## Overview
|
||||
|
||||
Deliver one vertical change: detect a newly installed Apprise from Notifications
|
||||
settings while preserving drafts. Execute one work order sequentially.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: manual detection, pending/result/error feedback, draft preservation,
|
||||
translations, desktop/mobile proof, and a short public how-to.
|
||||
|
||||
Out of scope: installation, imported configuration, automatic polling, new
|
||||
endpoints, notification delivery, and unrelated settings changes.
|
||||
|
||||
## Technical approach
|
||||
|
||||
Follow the [system design](../../specs/platform/system-design/apprise-rescan.md).
|
||||
Extend `useNotificationProviders` with an availability-only refresh. Add a
|
||||
narrow store setter and separate availability from guarded draft hydration.
|
||||
Wire a labeled button in `ExternalProvidersSection` and preserve open forms.
|
||||
The backend already evaluates `exec.LookPath("apprise")` for every list request.
|
||||
|
||||
## Tests
|
||||
|
||||
- `use-notification-providers.test.tsx`: missing-to-detected, detected-to-missing,
|
||||
duplicate clicks, failed request followed by retry, and delayed response after
|
||||
provider data changes (AC 001.2 through 001.4).
|
||||
- `notifications-settings-actions.test.tsx`: dirty name, URL, event, removal,
|
||||
and create-form drafts survive changed availability (AC 001.4).
|
||||
- `notifications-settings.test.tsx`: action in both availability states,
|
||||
feedback, unchanged save dirtiness, and retained open form (AC 001.1-001.4).
|
||||
|
||||
## E2E tests
|
||||
|
||||
- `settings/apprise-rescan.spec.ts`, project `chromium`: click Rescan Apprise,
|
||||
observe fresh detection, and retain an unsaved event selection; cover retry
|
||||
after a failed request (AC 001.1-001.4).
|
||||
- `settings/mobile-apprise-rescan.spec.ts`, project `mobile-chrome`: tap the
|
||||
action, observe detection and Add Apprise Provider, assert a 44px touch target
|
||||
and no document overflow (AC 001.2, 001.5).
|
||||
|
||||
Use controlled GET responses and deferred completion for deterministic checks.
|
||||
Do not install Apprise or send remote notifications from browser tests.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Add Apprise rescan](task-01-apprise-rescan.md) (`done`)
|
||||
|
||||
## Verification results
|
||||
|
||||
Implementation completed.
|
||||
|
||||
- Targeted Vitest: 4 files, 37 tests passed.
|
||||
- TypeScript typecheck passed.
|
||||
- Targeted ESLint passed with zero warnings.
|
||||
- `i18n:zh-hant` and `i18n:check` passed for all required catalogs.
|
||||
- Managed Chromium and mobile-chrome E2E scenarios passed, including fresh
|
||||
desktop and mobile captures.
|
||||
- Public-doc tests and validation passed for 46 pages.
|
||||
- Specification lint and `git diff --check` passed.
|
||||
|
||||
## Risks
|
||||
|
||||
- An executable installed outside the running backend's PATH stays undetected.
|
||||
- Updating the full provider store from a delayed rescan could revert a save;
|
||||
the availability-only setter prevents that overwrite.
|
||||
- Both notifications source files are already large. Extract only a focused
|
||||
control if needed to meet lint limits; avoid broad component refactoring.
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
id: "01-apprise-rescan"
|
||||
title: "Add Apprise rescan"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-PLATFORM-APPRISE-RESCAN-001
|
||||
acceptance_criteria:
|
||||
- AC-PLATFORM-APPRISE-RESCAN-001.1
|
||||
- AC-PLATFORM-APPRISE-RESCAN-001.2
|
||||
- AC-PLATFORM-APPRISE-RESCAN-001.3
|
||||
- AC-PLATFORM-APPRISE-RESCAN-001.4
|
||||
- AC-PLATFORM-APPRISE-RESCAN-001.5
|
||||
system_design:
|
||||
- ../../specs/platform/system-design/apprise-rescan.md
|
||||
---
|
||||
|
||||
# Task 01: Add Apprise Rescan
|
||||
|
||||
## Summary
|
||||
|
||||
Add the Notifications settings rescan action using the existing provider-list
|
||||
API. Apply only availability updates and preserve editable settings.
|
||||
|
||||
## In scope
|
||||
|
||||
- Domain hook, narrow store setter, availability hydration, and control wiring.
|
||||
- Loading, result, error/retry, draft preservation, and translations.
|
||||
- Targeted tests, mobile geometry, and a public Notifications how-to section.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Installation, new API contracts, automatic polling, sending notifications,
|
||||
schema changes, and broad settings refactoring.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. The labeled action updates detection in both directions; duplicate calls
|
||||
are blocked and a failed request retains status until a successful retry.
|
||||
2. Dirty settings and open forms survive every result; a delayed rescan cannot
|
||||
overwrite provider data saved while it was pending.
|
||||
3. Desktop and mobile flows pass targeted tests, all copy is localized, and the
|
||||
public instructions explain the backend environment boundary.
|
||||
|
||||
## Verification
|
||||
|
||||
Use TDD for changed logic. From `apps/`, install once before package commands:
|
||||
|
||||
```bash
|
||||
rtk pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
From `apps/web/`:
|
||||
|
||||
```bash
|
||||
rtk pnpm exec vitest run hooks/domains/settings/use-notification-providers.test.tsx components/settings/notifications-settings-actions.test.tsx components/settings/notifications-settings.test.tsx
|
||||
rtk pnpm run typecheck
|
||||
rtk pnpm exec eslint hooks/domains/settings/use-notification-providers.ts components/settings/notifications-settings-actions.ts components/settings/notifications-settings.tsx lib/state/slices/settings/settings-slice.ts lib/state/slices/settings/types.ts
|
||||
rtk pnpm run i18n:zh-hant
|
||||
rtk pnpm run i18n:check
|
||||
rtk pnpm e2e:run --project chromium tests/settings/apprise-rescan.spec.ts
|
||||
rtk pnpm e2e:run --project mobile-chrome tests/settings/mobile-apprise-rescan.spec.ts
|
||||
```
|
||||
|
||||
Include any extracted production component in the targeted lint command.
|
||||
Use normal managed builds and inspect rendered desktop/mobile captures.
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
rtk node --test scripts/validate-public-docs.test.mjs
|
||||
rtk node scripts/validate-public-docs.mjs
|
||||
rtk python3 scripts/lint-spec-files.py --all
|
||||
rtk git diff --check
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/hooks/domains/settings/use-notification-providers.ts` and new test.
|
||||
- `apps/web/lib/state/slices/settings/settings-slice.ts` and `types.ts`.
|
||||
- `apps/web/components/settings/notifications-settings-actions.ts` and its test.
|
||||
- `apps/web/components/settings/notifications-settings.tsx`, the extracted
|
||||
external-provider section, and its test.
|
||||
- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/settings.json`.
|
||||
- `apps/web/e2e/tests/settings/{apprise-rescan,mobile-apprise-rescan}.spec.ts`
|
||||
and a shared helper if needed.
|
||||
- `docs/public/developer-tools.md`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
See the plan. Keep create-form input mounted when availability becomes false.
|
||||
Use the narrow store setter so captured provider arrays cannot revert a save.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- [Requirements](../../specs/platform/requirements/apprise-rescan.md).
|
||||
- [System design](../../specs/platform/system-design/apprise-rescan.md).
|
||||
- Existing `notification-permission-section.tsx` refresh control.
|
||||
- Existing `mobile-notification-events.spec.ts` for settings fixture and geometry.
|
||||
- Repository `/tdd`, `/mobile-parity`, `/e2e`, and `/docs-maintainer` guidance.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented with an availability-only rescan path and localized pending,
|
||||
result, and retry feedback. The store setter updates only Apprise availability,
|
||||
so provider drafts, removals, open forms, and concurrent provider saves are not
|
||||
replaced.
|
||||
|
||||
Verification completed:
|
||||
|
||||
- `pnpm install --frozen-lockfile` completed from `apps/`.
|
||||
- Targeted Vitest passed: 4 files, 37 tests.
|
||||
- Typecheck passed.
|
||||
- Targeted ESLint passed with `--max-warnings 0`.
|
||||
- Traditional Chinese generation and the complete i18n gate passed.
|
||||
- Chromium and mobile-chrome managed E2E tests passed, including desktop and
|
||||
mobile geometry/overflow assertions and fresh PR captures.
|
||||
- Public-doc test suite passed (61 tests), public-doc validation passed (46
|
||||
pages), specification lint passed, and `git diff --check` passed.
|
||||
@@ -13,6 +13,17 @@ Kandev includes short-lived chat, reusable AI helpers, dictation, file and edito
|
||||
2. Use a task session for work that needs files, review, or workflow state.
|
||||
3. Add utility agents, editors, language servers, or terminals only when their host boundary is acceptable.
|
||||
|
||||
## Configure Apprise notifications
|
||||
|
||||
To detect Apprise after you install it, open **Settings > Preferences > Notifications** and select
|
||||
**Rescan Apprise** under **External Providers**. Kandev checks for the `apprise` executable in the
|
||||
environment of the running Kandev backend. When it is found, **Add Apprise Provider** becomes
|
||||
available. Rescanning does not save notification drafts or send a notification.
|
||||
|
||||
If Kandev does not detect Apprise, check the `PATH` used by the backend process. If you install
|
||||
Apprise in a directory already in that `PATH`, rescan. If you install it elsewhere or change `PATH`
|
||||
after Kandev starts, restart Kandev so the running backend receives the new environment, then rescan.
|
||||
|
||||
## Quick Chat
|
||||
|
||||
Quick Chat is an agent conversation outside the board. Use it for repository orientation, experiments, and disposable questions that do not need workflow state, review gates, dependencies, or a delivery record.
|
||||
|
||||
@@ -60,6 +60,7 @@ localization, feature toggles, health, and shared session recovery services.
|
||||
- [Session MCP Attachment Observability](requirements/mcp-session-observability.md)
|
||||
- [Mid-Turn Steering](requirements/mid-turn-steering.md)
|
||||
- [Semantic Notifications](requirements/notifications.md)
|
||||
- [Apprise rescan](requirements/apprise-rescan.md)
|
||||
- [Required Persisted Store Parity](requirements/postgres-domain-store-parity.md)
|
||||
- [Provider Error Recovery](requirements/provider-error-recovery.md)
|
||||
- [Session Config Reconciliation Across Agent Types](requirements/session-config-cross-agent-reconcile.md)
|
||||
@@ -78,6 +79,7 @@ localization, feature toggles, health, and shared session recovery services.
|
||||
|
||||
- [Agent process exit and stderr drain](system-design/agent-process-exit-drain.md)
|
||||
- [Agentctl instance stop idempotency](system-design/agentctl-instance-stop.md)
|
||||
- [Apprise rescan](system-design/apprise-rescan.md)
|
||||
- [Background Work Liveness](system-design/background-work-liveness.md)
|
||||
- [Backend restart page recovery](system-design/backend-restart-page-recovery.md)
|
||||
- [Browser console retention](system-design/browser-console-retention.md)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
status: draft
|
||||
system: platform
|
||||
created: 2026-09-09
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Apprise Rescan Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
Users can detect Apprise after installing it while Kandev is running.
|
||||
Platform owns this capability because it owns notification provider availability.
|
||||
This extends provider discovery; [semantic notifications](notifications.md)
|
||||
continues to own event selection and delivery.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-PLATFORM-APPRISE-RESCAN-001: Manual Apprise detection
|
||||
|
||||
**Intent:** Refresh Apprise availability from Notifications settings without
|
||||
reloading the page or losing settings drafts.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-PLATFORM-APPRISE-RESCAN-001.1:** External Providers shall offer a
|
||||
labeled rescan action whether Apprise is detected or missing.
|
||||
- **AC-PLATFORM-APPRISE-RESCAN-001.2:** When a rescan completes, the page shall
|
||||
show the current detection result and update access to Add Apprise Provider.
|
||||
Detection shall use the environment where the Kandev backend runs.
|
||||
- **AC-PLATFORM-APPRISE-RESCAN-001.3:** While checking, the action shall show
|
||||
progress and prevent duplicate requests. After a request error, it shall show
|
||||
retry feedback and retain the last known availability.
|
||||
- **AC-PLATFORM-APPRISE-RESCAN-001.4:** Rescanning shall preserve unsaved names,
|
||||
URLs, event selections, removals, and open forms. It shall neither save settings
|
||||
nor send notifications. A rescan alone shall not mark settings dirty.
|
||||
- **AC-PLATFORM-APPRISE-RESCAN-001.5:** Desktop and mobile shall expose the same
|
||||
action and feedback. It shall support keyboard activation, translated copy,
|
||||
a touch target at least 44px high, and no document horizontal overflow.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Installing Apprise, importing its configuration, or discovering service URLs.
|
||||
- Searching another machine or changing the running backend's environment.
|
||||
- Automatic polling, new provider types, or changes to notification delivery.
|
||||
- General notification loading-error redesign or unrelated settings layout.
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
status: draft
|
||||
system: platform
|
||||
requirements:
|
||||
- REQ-PLATFORM-APPRISE-RESCAN-001
|
||||
---
|
||||
|
||||
# Apprise Rescan System Design
|
||||
|
||||
## Boundaries and evidence
|
||||
|
||||
`Controller.ListProviders` already returns freshly evaluated
|
||||
`apprise_available` from `AppriseProvider.Available`, which calls
|
||||
`exec.LookPath("apprise")`. Reuse `GET /api/v1/notification-providers` through
|
||||
`listNotificationProviders({ cache: "no-store" })`. No new endpoint is needed.
|
||||
|
||||
`useNotificationProviders` currently loads once. `useNotificationsState`
|
||||
copies availability into local state inside its draft hydration guard.
|
||||
That guard can prevent availability updates while provider edits are pending.
|
||||
|
||||
Platform owns detection. Existing settings authorization and
|
||||
[semantic notification rules](../requirements/notifications.md) remain the
|
||||
boundaries for configuration and delivery.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Acceptance criteria | Design section |
|
||||
| --- | --- |
|
||||
| 001.1, 001.2 | Request and state flow |
|
||||
| 001.3, 001.4 | Failure and draft preservation |
|
||||
| 001.5 | Desktop and mobile presentation |
|
||||
|
||||
All rows refer to `AC-PLATFORM-APPRISE-RESCAN-*`.
|
||||
|
||||
## Request and state flow
|
||||
|
||||
Expose an explicit availability refresh from `useNotificationProviders`.
|
||||
The domain hook owns the request, pending state, and request failure.
|
||||
Wait for initial provider loading to finish before enabling manual refresh.
|
||||
Guard duplicate calls synchronously as well as disabling the button.
|
||||
|
||||
Consume only `apprise_available` from the manual response. Add a narrow
|
||||
`setAppriseAvailable` settings-store action so a delayed response cannot replace
|
||||
provider records, events, or loading state changed by another operation.
|
||||
Keep the existing initial-load behavior separate.
|
||||
|
||||
Read availability directly from the domain store in `useNotificationsState`,
|
||||
or synchronize it independently of the provider draft guard. Remove obsolete
|
||||
local availability setters after checking all callers. Preserve the existing
|
||||
initial unknown-state fallback in this scoped change.
|
||||
|
||||
## Failure and draft preservation
|
||||
|
||||
The refresh changes availability only. Preserve provider baselines, drafts,
|
||||
pending removals, name and URL input, and event selections on success or error.
|
||||
This also avoids overwriting a provider save that finishes during a rescan.
|
||||
Ignore response provider arrays for manual rescans.
|
||||
|
||||
Clear the previous request error when retrying. On failure, retain availability
|
||||
and show localized inline retry feedback. Always clear pending state.
|
||||
An unavailable result is a completed check, not a request failure.
|
||||
Do not hide an already open create form if a rescan reports Apprise missing;
|
||||
retain its input and keep the existing save behavior authoritative.
|
||||
|
||||
The action does not invoke create, update, delete, test-notification, or browser
|
||||
permission APIs. It creates no persisted settings or schema changes.
|
||||
|
||||
## Desktop and mobile presentation
|
||||
|
||||
Entry point: `/settings/preferences/notifications`, External Providers.
|
||||
Use an outline `Button` labeled `Rescan Apprise`, with the existing refresh icon.
|
||||
Show `Checking...` while pending and a short detected/not-detected result after
|
||||
completion. Explain visibly that detection runs on the Kandev server.
|
||||
Keep the empty configured-provider message distinct from binary availability.
|
||||
|
||||
The local exemplar is `DesktopNotificationsSection` for refresh placement.
|
||||
The curated `mobile-fab.tsx` exemplar contributes an explicit touch target and
|
||||
pressed feedback; this contextual settings action stays inline.
|
||||
Use a header row on desktop and a wrapping or stacked action below the heading
|
||||
on phones. The action needs no picker, drawer, or new route because it has no
|
||||
choices or deep content. Keep the existing settings page as the scroll owner.
|
||||
|
||||
Maintain normal desktop button density and apply the 44px minimum only for
|
||||
phone/coarse-pointer use. No fixed control or additional safe-area container is
|
||||
needed. Use a semantic button, accessible status/error feedback, and visible
|
||||
focus styling. Share the hook and action handler across viewports.
|
||||
|
||||
Add copy in English, Portuguese, and Simplified Chinese, then generate both
|
||||
Traditional Chinese catalogs with `pnpm run i18n:zh-hant`.
|
||||
|
||||
## Validation and documentation
|
||||
|
||||
Hook tests cover detection changes, duplicate prevention, failure/retry, and a
|
||||
save that updates provider data before a delayed rescan resolves.
|
||||
Settings tests cover availability updates during dirty drafts and preservation
|
||||
of open create/edit forms. Desktop and mobile Playwright tests exercise the
|
||||
button using controlled provider-list responses; mobile also checks geometry.
|
||||
|
||||
The existing backend lookup is reused without production Go changes.
|
||||
Add a short Notifications how-to section to `docs/public/developer-tools.md`
|
||||
during implementation. Explain server-local detection and that changing PATH
|
||||
outside the running process can still require restarting Kandev.
|
||||
|
||||
No ADR is needed: this reuses the current endpoint, permissions, lookup method,
|
||||
and configuration ownership.
|
||||
Reference in New Issue
Block a user