mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
feat(editor): move signing sessions onto TanStack Query (#7436)
# Description of Changes Step 4 of the TanStack Query rollout, and the first of the polling hooks. Follows #7264, #7283, #7285. ## The problem `useSigningSessions` hand-rolled its own fetch, loading state and `setInterval`. Two consequences: - **A raw `setInterval` keeps polling a hidden tab.** Browsers throttle background timers, they do not stop them, so a backgrounded editor with Shared Sign open keeps hitting both endpoints for as long as it is open. - **No tests.** The hook had none, and its quietest behaviour (below) is the easiest thing to break without noticing. ## End state One query behind `qk.signingSessions()`, with the polling lifecycle handed to the library: - Polling stops while the tab is hidden, and refetches on return rather than leaving data up to a full interval stale. - Mounts render from cache while they revalidate, so moving between the tool picker and the signing tool no longer flashes an empty list. - 12 tests where there were none. Same return shape, so no consumer files change. ### What this is not This is not a deduplication win. The three consumers are never mounted at the same time: `ToolPanel` renders the tool picker or the active tool and never both, so the badge cannot be on screen with either of the others, and `SharedSigningLauncher` and `useSigningSessionController` sit inside two different tools. The shared key earns its keep on cache reuse across those transitions, not on concurrent fetches. ## The bit worth reviewing The hand-rolled `{ silent: true }` flag encoded three states, and no single Query flag reproduces them: | | Spinner | Toast on failure | |---|---|---| | First load | yes | yes | | Background poll | no | no | | Explicit refetch | **yes** | **yes** | `isLoading` is false during an explicit refetch when data is already on screen; `isFetching` is true during a background poll. Neither matches, so the user-initiated case is tracked with a small flag and the failure toast is gated on `isLoadingError` plus the explicit path. ## Testing Twelve tests. Rather than trust them, each claim was checked by breaking the implementation and confirming the relevant test fails: | Mutation | Caught by | |---|---| | `refetchIntervalInBackground: true` | hidden-tab test | | Drop `refetchOnWindowFocus` | returns-to-view test | | Drop the user-initiated spinner flag | manual-refresh test | | Toast on every error | background-failure-is-silent test | | Give each observer its own key | dedupe test | Three things worth knowing for the next conversion: - **`waitFor` flushes renders.** Recording an index *after* `waitFor(callCount === 2)` skips past the in-flight render, so a "did the spinner flip on" assertion passes vacuously. The marker has to go before the poll. - **Fake timers hide in-flight state.** The fetch settles inside the same `act()`, so the intermediate render never happens. That test uses real timers and a held-open promise. - **`visibilitychange` has to bubble.** query-core listens for it on `window`, and the real event bubbles from `document`. A test helper dispatching a non-bubbling event never reaches the focus manager, and the pause behaviour still appears to work because `refetchInterval` reads `document.visibilityState` directly at tick time rather than through the event. **One claim is deliberately unguarded.** `isLoading` vs `isFetching` for a background poll produces no re-render at all, so there is nothing observable for a test to assert and no user-visible difference to protect. ## Pre-existing failures `task frontend:check` passes typecheck, lint and oxfmt, and 2363 of 2365 editor tests. The two failures, `workbenchSession.test.ts` and `notificationActions.test.tsx`, fail identically with this branch's changes reverted and are untouched by it. ## Scope This is one of five pollers. The remaining four, `useLocalFolderPoller`, `WatchedFolderWorkbenchView`, `SessionDetailPanel` and cloud `TeamSection`, are separate files with their own consumers and follow separately, now that the silent-refresh pattern has a worked example. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
co-authored by
Anthony Stirling
parent
c22d9ecf58
commit
ead8a536d2
@@ -0,0 +1,21 @@
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import type {
|
||||
SignRequestSummary,
|
||||
SessionSummary,
|
||||
} from "@app/types/signingSession";
|
||||
|
||||
export interface SigningSessions {
|
||||
signRequests: SignRequestSummary[];
|
||||
mySessions: SessionSummary[];
|
||||
}
|
||||
|
||||
/** The two lists the signing UI always needs together. */
|
||||
export async function fetchSigningSessions(): Promise<SigningSessions> {
|
||||
const [requests, sessions] = await Promise.all([
|
||||
apiClient.get<SignRequestSummary[]>(
|
||||
"/api/v1/security/cert-sign/sign-requests",
|
||||
),
|
||||
apiClient.get<SessionSummary[]>("/api/v1/security/cert-sign/sessions"),
|
||||
]);
|
||||
return { signRequests: requests.data, mySessions: sessions.data };
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { baseQueryOptions } from "@app/query/queryClient";
|
||||
import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider";
|
||||
import { useSigningSessions } from "@app/hooks/signing/useSigningSessions";
|
||||
import { fetchSigningSessions } from "@app/api/signing";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
vi.mock("@app/api/signing", () => ({ fetchSigningSessions: vi.fn() }));
|
||||
vi.mock("@app/components/toast", () => ({ alert: vi.fn() }));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_k: string, fallback?: string) => fallback ?? _k,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchSigningSessions);
|
||||
const mockAlert = vi.mocked(alert);
|
||||
|
||||
const EMPTY = { signRequests: [], mySessions: [] };
|
||||
|
||||
function setVisibility(state: "visible" | "hidden") {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => state,
|
||||
});
|
||||
// Bubbles, as the real event does: query-core listens for it on window.
|
||||
document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("useSigningSessions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetch.mockResolvedValue(EMPTY);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
setVisibility("visible");
|
||||
});
|
||||
|
||||
it("dedupes concurrent observers of the same key", async () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
badge: useSigningSessions({
|
||||
enabled: true,
|
||||
autoRefreshInterval: 60000,
|
||||
}),
|
||||
launcher: useSigningSessions({ enabled: true }),
|
||||
controller: useSigningSessions({
|
||||
enabled: true,
|
||||
autoRefreshInterval: 15000,
|
||||
}),
|
||||
}),
|
||||
{ wrapper: TestQueryProvider },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.badge.loading).toBe(false));
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fetch while disabled", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(
|
||||
() => useSigningSessions({ enabled: false, autoRefreshInterval: 15000 }),
|
||||
{ wrapper: TestQueryProvider },
|
||||
);
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60000);
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(result.current.signRequests).toEqual([]);
|
||||
});
|
||||
|
||||
it("starts fetching when enabled flips on", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ on }: { on: boolean }) => useSigningSessions({ enabled: on }),
|
||||
{ wrapper: TestQueryProvider, initialProps: { on: false } },
|
||||
);
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
rerender({ on: true });
|
||||
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
});
|
||||
|
||||
it("polls on the interval", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(
|
||||
() => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
|
||||
{ wrapper: TestQueryProvider },
|
||||
);
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not raise the spinner while a background poll is in flight", async () => {
|
||||
// Real timers, a held-open poll, and every render recorded. Asserting on
|
||||
// result.current alone is not enough: waitFor returns as soon as the fetch
|
||||
// count moves, before React has re-rendered, so a spinner that did flip on
|
||||
// would be missed.
|
||||
const seen: boolean[] = [];
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const state = useSigningSessions({
|
||||
enabled: true,
|
||||
autoRefreshInterval: 50,
|
||||
});
|
||||
seen.push(state.loading);
|
||||
return state;
|
||||
},
|
||||
{ wrapper: TestQueryProvider },
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// Marked before the poll: waitFor flushes renders, so recording after it
|
||||
// would skip straight past the in-flight one.
|
||||
const fromPollStart = seen.length;
|
||||
|
||||
let release: (v: unknown) => void = () => {};
|
||||
mockFetch.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}) as never,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2));
|
||||
|
||||
// Give React room to render the in-flight state, if it produces one.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
});
|
||||
|
||||
// Mid-poll: this is what the old `silent` flag bought.
|
||||
expect(seen.slice(fromPollStart)).not.toContain(true);
|
||||
expect(result.current.loading).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
release(EMPTY);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the spinner for a user-initiated refresh, not a background poll", async () => {
|
||||
// Real timers: the in-flight window has to be observable, which is exactly
|
||||
// what a fake-timer act() hides.
|
||||
const { result } = renderHook(() => useSigningSessions({ enabled: true }), {
|
||||
wrapper: TestQueryProvider,
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
let release: (v: unknown) => void = () => {};
|
||||
mockFetch.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}) as never,
|
||||
);
|
||||
|
||||
let done: Promise<void>;
|
||||
act(() => {
|
||||
done = result.current.refetch();
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(true));
|
||||
|
||||
await act(async () => {
|
||||
release(EMPTY);
|
||||
await done;
|
||||
});
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("toasts a first-load failure", async () => {
|
||||
expectConsole.error(/Failed to fetch signing data/);
|
||||
mockFetch.mockRejectedValue(new Error("down"));
|
||||
|
||||
const { result } = renderHook(() => useSigningSessions({ enabled: true }), {
|
||||
wrapper: TestQueryProvider,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeTruthy());
|
||||
expect(mockAlert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays silent when a background poll fails after a success", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockFetch.mockResolvedValueOnce(EMPTY);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
|
||||
{ wrapper: TestQueryProvider },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(mockAlert).not.toHaveBeenCalled();
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("flaky"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toasts an explicit refetch failure even with data on screen", async () => {
|
||||
expectConsole.error(/Failed to fetch signing data/);
|
||||
const { result } = renderHook(() => useSigningSessions({ enabled: true }), {
|
||||
wrapper: TestQueryProvider,
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(mockAlert).not.toHaveBeenCalled();
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("nope"));
|
||||
await act(async () => {
|
||||
await result.current.refetch();
|
||||
});
|
||||
|
||||
expect(mockAlert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops polling while the tab is hidden", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(
|
||||
() => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
|
||||
{ wrapper: TestQueryProvider },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
setVisibility("hidden");
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60000);
|
||||
});
|
||||
// Four intervals elapsed with the tab in the background.
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
setVisibility("visible");
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
});
|
||||
expect(mockFetch.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("refetches on becoming visible rather than waiting out the interval", async () => {
|
||||
vi.useFakeTimers();
|
||||
// The app client turns focus refetching off globally; TestQueryProvider
|
||||
// does not, and would pass this on the library default alone.
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { ...baseQueryOptions, retry: false, gcTime: Infinity },
|
||||
},
|
||||
});
|
||||
const { result } = renderHook(
|
||||
() => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
|
||||
{
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
setVisibility("hidden");
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60000);
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
setVisibility("visible");
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stops polling once unmounted", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { unmount } = renderHook(
|
||||
() => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
|
||||
{ wrapper: TestQueryProvider },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60000);
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { fetchSigningSessions } from "@app/api/signing";
|
||||
import { qk } from "@app/query/keys";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { SignRequestSummary, SessionSummary } from "@app/types/signingSession";
|
||||
|
||||
const EMPTY_REQUESTS: SignRequestSummary[] = [];
|
||||
const EMPTY_SESSIONS: SessionSummary[] = [];
|
||||
|
||||
export interface UseSigningSessionsOptions {
|
||||
enabled?: boolean;
|
||||
autoRefreshInterval?: number; // milliseconds, 0 to disable
|
||||
@@ -18,8 +23,8 @@ export interface UseSigningSessionsResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch signing sessions data (sign requests and user's sessions).
|
||||
* Supports auto-refresh for real-time updates.
|
||||
* Signing sessions. Background polls never raise the spinner or a toast; only a
|
||||
* first load or an explicit refetch does.
|
||||
*/
|
||||
export const useSigningSessions = (
|
||||
options: UseSigningSessionsOptions = {},
|
||||
@@ -27,83 +32,64 @@ export const useSigningSessions = (
|
||||
const { enabled = true, autoRefreshInterval = 0 } = options;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [signRequests, setSignRequests] = useState<SignRequestSummary[]>([]);
|
||||
const [mySessions, setMySessions] = useState<SessionSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const { data, isLoading, isLoadingError, error, refetch } = useQuery({
|
||||
queryKey: qk.signingSessions(),
|
||||
queryFn: fetchSigningSessions,
|
||||
enabled,
|
||||
staleTime: 0,
|
||||
refetchInterval: autoRefreshInterval > 0 ? autoRefreshInterval : false,
|
||||
refetchIntervalInBackground: false,
|
||||
// The interval pauses while unfocused, so returning has to catch up: the
|
||||
// client-wide default of false would hold stale data until the next tick.
|
||||
refetchOnWindowFocus: autoRefreshInterval > 0,
|
||||
});
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (opts?: { silent?: boolean }) => {
|
||||
if (!enabled) return;
|
||||
const notifyFailure = useCallback(() => {
|
||||
console.error("Failed to fetch signing data");
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: t("common.error"),
|
||||
body: t("certSign.fetchFailed", "Failed to load signing data"),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
// Background auto-refreshes pass { silent: true } to skip the loading spinner
|
||||
// and failure toasts; only the initial load and explicit refetch surface errors.
|
||||
const silent = opts?.silent ?? false;
|
||||
|
||||
if (!silent) setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [requestsResponse, sessionsResponse] = await Promise.all([
|
||||
apiClient.get<SignRequestSummary[]>(
|
||||
"/api/v1/security/cert-sign/sign-requests",
|
||||
),
|
||||
apiClient.get<SessionSummary[]>(
|
||||
"/api/v1/security/cert-sign/sessions",
|
||||
),
|
||||
]);
|
||||
|
||||
setSignRequests(requestsResponse.data);
|
||||
setMySessions(sessionsResponse.data);
|
||||
} catch (err) {
|
||||
const errorObj =
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error("Failed to fetch signing data");
|
||||
setError(errorObj);
|
||||
console.error("Failed to fetch signing data:", err);
|
||||
|
||||
if (!silent) {
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: t("common.error"),
|
||||
body: t("certSign.fetchFailed", "Failed to load signing data"),
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
},
|
||||
[enabled, t],
|
||||
);
|
||||
|
||||
// Initial fetch
|
||||
// isLoadingError is "failed with nothing cached", i.e. a first load. A poll
|
||||
// that fails after a success keeps the old data and stays silent.
|
||||
const reportedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
fetchData();
|
||||
}
|
||||
}, [enabled, fetchData]);
|
||||
|
||||
// Auto-refresh
|
||||
useEffect(() => {
|
||||
if (!enabled || !autoRefreshInterval || autoRefreshInterval <= 0) {
|
||||
if (!isLoadingError) {
|
||||
reportedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (reportedRef.current) return;
|
||||
reportedRef.current = true;
|
||||
notifyFailure();
|
||||
}, [isLoadingError, notifyFailure]);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchData({ silent: true });
|
||||
}, autoRefreshInterval);
|
||||
// Neither isLoading nor isFetching alone matches the old `silent` flag: a
|
||||
// user-initiated refresh showed the spinner even with data on screen, a
|
||||
// background poll never did. isFetching cannot tell them apart, so track it.
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [enabled, autoRefreshInterval, fetchData]);
|
||||
const explicitRefetch = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const result = await refetch();
|
||||
// Reported here rather than by the effect: a user-initiated refresh
|
||||
// should say so even when stale data is already on screen.
|
||||
if (result.error && !reportedRef.current) notifyFailure();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [refetch, notifyFailure]);
|
||||
|
||||
return {
|
||||
signRequests,
|
||||
mySessions,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchData,
|
||||
signRequests: data?.signRequests ?? EMPTY_REQUESTS,
|
||||
mySessions: data?.mySessions ?? EMPTY_SESSIONS,
|
||||
loading: isLoading || refreshing,
|
||||
error: (error as Error | null) ?? null,
|
||||
refetch: explicitRefetch,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export const qk = {
|
||||
["editor", "endpointEnabled", endpoint] as const,
|
||||
footerInfo: () => ["editor", "footerInfo"] as const,
|
||||
groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const,
|
||||
signingSessions: () => ["editor", "signingSessions"] as const,
|
||||
/** Keyed on the asking identity: two users must never share one answer. */
|
||||
portalAccess: (userId: string | null) =>
|
||||
["editor", "portalAccess", userId] as const,
|
||||
|
||||
Reference in New Issue
Block a user