@remit/web-client 0.0.237 → 0.0.239
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/mail/IntelligencePane.test.ts +34 -0
- package/src/components/mail/IntelligencePane.tsx +15 -1
- package/src/components/mail/SelectionWizardHost.tsx +8 -1
- package/src/components/mail/intelligence-semantic-off.render.test.ts +128 -0
- package/src/components/settings/CalendarFeedCard.render.test.ts +154 -0
- package/src/components/settings/CalendarFeedCard.stories.tsx +134 -0
- package/src/components/settings/CalendarFeedCard.tsx +282 -0
- package/src/components/settings/CalendarFeedPanel.tsx +34 -0
- package/src/components/settings/calendar-feed-flow.render.test.ts +449 -0
- package/src/hooks/calendar/useCalendarFeed.ts +163 -0
- package/src/hooks/useIntelligenceData.ts +16 -1
- package/src/hooks/useSemanticSearchEnabled.ts +23 -0
- package/src/lib/calendar-feed-copy.ts +29 -0
- package/src/lib/calendar-feed-url.ts +14 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/settings/calendars.tsx +73 -0
- package/src/routes/settings.tsx +15 -1
- package/src/test-support/fixtures.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.239",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -36,6 +36,7 @@ describe("resolveSimilarState — fail-fast on similar-messages search", () => {
|
|
|
36
36
|
similarError: new Error("500"),
|
|
37
37
|
similarErrorIsFatal: true,
|
|
38
38
|
isSimilarLoading: false,
|
|
39
|
+
semanticEnabled: true,
|
|
39
40
|
}),
|
|
40
41
|
"ready",
|
|
41
42
|
);
|
|
@@ -47,6 +48,7 @@ describe("resolveSimilarState — fail-fast on similar-messages search", () => {
|
|
|
47
48
|
similarError: new Error("404"),
|
|
48
49
|
similarErrorIsFatal: false,
|
|
49
50
|
isSimilarLoading: false,
|
|
51
|
+
semanticEnabled: true,
|
|
50
52
|
}),
|
|
51
53
|
"error",
|
|
52
54
|
);
|
|
@@ -58,6 +60,7 @@ describe("resolveSimilarState — fail-fast on similar-messages search", () => {
|
|
|
58
60
|
similarError: null,
|
|
59
61
|
similarErrorIsFatal: false,
|
|
60
62
|
isSimilarLoading: false,
|
|
63
|
+
semanticEnabled: true,
|
|
61
64
|
}),
|
|
62
65
|
"ready",
|
|
63
66
|
);
|
|
@@ -69,8 +72,39 @@ describe("resolveSimilarState — fail-fast on similar-messages search", () => {
|
|
|
69
72
|
similarError: null,
|
|
70
73
|
similarErrorIsFatal: false,
|
|
71
74
|
isSimilarLoading: true,
|
|
75
|
+
semanticEnabled: true,
|
|
72
76
|
}),
|
|
73
77
|
"loading",
|
|
74
78
|
);
|
|
75
79
|
});
|
|
76
80
|
});
|
|
81
|
+
|
|
82
|
+
// Off is a property of the deployment, and it leads: nothing was embedded, so
|
|
83
|
+
// there was no query, and the section states the setting instead of an empty
|
|
84
|
+
// list (#1068). `undefined` is the config read still in flight, which is not
|
|
85
|
+
// an answer.
|
|
86
|
+
describe("resolveSimilarState — semantic search off on this instance", () => {
|
|
87
|
+
test("off wins over every runtime state", () => {
|
|
88
|
+
assert.equal(
|
|
89
|
+
resolveSimilarState({
|
|
90
|
+
similarError: new Error("404"),
|
|
91
|
+
similarErrorIsFatal: false,
|
|
92
|
+
isSimilarLoading: true,
|
|
93
|
+
semanticEnabled: false,
|
|
94
|
+
}),
|
|
95
|
+
"off",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a config read still in flight is not an off state", () => {
|
|
100
|
+
assert.equal(
|
|
101
|
+
resolveSimilarState({
|
|
102
|
+
similarError: null,
|
|
103
|
+
similarErrorIsFatal: false,
|
|
104
|
+
isSimilarLoading: false,
|
|
105
|
+
semanticEnabled: undefined,
|
|
106
|
+
}),
|
|
107
|
+
"ready",
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -82,6 +82,11 @@ export const resolveSpamAction = (thread: {
|
|
|
82
82
|
/**
|
|
83
83
|
* Decide the "Similar messages" section state from the semantic-search query.
|
|
84
84
|
*
|
|
85
|
+
* An instance with semantic search off leads, before any error or in-flight
|
|
86
|
+
* state: nothing is embedded, no query was sent, and the section states the
|
|
87
|
+
* setting (#1068). Only `false` counts — the config read has not answered yet
|
|
88
|
+
* while it is `undefined`, and an off state shown then would be a guess.
|
|
89
|
+
*
|
|
85
90
|
* The fail-fast rule: a fatal first-party 5xx must NEVER degrade to the benign
|
|
86
91
|
* grey "Similarity search unavailable" label — it escalates to the global red
|
|
87
92
|
* overlay instead, so this returns `"ready"` for it (the section then renders
|
|
@@ -93,8 +98,15 @@ export const resolveSimilarState = (input: {
|
|
|
93
98
|
similarError: unknown;
|
|
94
99
|
similarErrorIsFatal: boolean;
|
|
95
100
|
isSimilarLoading: boolean;
|
|
101
|
+
semanticEnabled: boolean | undefined;
|
|
96
102
|
}): SimilarState => {
|
|
97
|
-
const {
|
|
103
|
+
const {
|
|
104
|
+
similarError,
|
|
105
|
+
similarErrorIsFatal,
|
|
106
|
+
isSimilarLoading,
|
|
107
|
+
semanticEnabled,
|
|
108
|
+
} = input;
|
|
109
|
+
if (semanticEnabled === false) return "off";
|
|
98
110
|
if (similarError && !similarErrorIsFatal) return "error";
|
|
99
111
|
if (isSimilarLoading) return "loading";
|
|
100
112
|
return "ready";
|
|
@@ -228,6 +240,7 @@ function WiredPanel({
|
|
|
228
240
|
isSimilarLoading,
|
|
229
241
|
similarError,
|
|
230
242
|
similarErrorIsFatal,
|
|
243
|
+
semanticEnabled,
|
|
231
244
|
} = useIntelligenceData(thread, mailboxId);
|
|
232
245
|
const [reclassifyOpen, setReclassifyOpen] = useState(false);
|
|
233
246
|
const senderEmail = thread.fromEmail ?? undefined;
|
|
@@ -336,6 +349,7 @@ function WiredPanel({
|
|
|
336
349
|
similarError,
|
|
337
350
|
similarErrorIsFatal,
|
|
338
351
|
isSimilarLoading,
|
|
352
|
+
semanticEnabled,
|
|
339
353
|
});
|
|
340
354
|
|
|
341
355
|
return (
|
|
@@ -49,6 +49,7 @@ import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
|
49
49
|
import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
|
|
50
50
|
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
51
51
|
import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
52
|
+
import { useSemanticSearchEnabled } from "@/hooks/useSemanticSearchEnabled";
|
|
52
53
|
import type {
|
|
53
54
|
BulkActionProgress,
|
|
54
55
|
BulkActionTarget,
|
|
@@ -563,7 +564,12 @@ function SelectionWizardSession({
|
|
|
563
564
|
// unavailable while the user is already holding it. The fallback is taken then
|
|
564
565
|
// rather than left as a door that counts nothing: the property step says, in
|
|
565
566
|
// so many words, that these are the senders it substituted (#477 3.6).
|
|
566
|
-
|
|
567
|
+
// Off is a deployment setting, not a probe result, and it reaches the door the
|
|
568
|
+
// same way the runtime failure does: dimmed, pressable, and falling back to
|
|
569
|
+
// the senders. Only the copy differs (#1068).
|
|
570
|
+
const semanticOff = useSemanticSearchEnabled() === false;
|
|
571
|
+
const semanticUnavailable =
|
|
572
|
+
semanticOff || widen.semanticUnavailable || widen.isError;
|
|
567
573
|
// Only while the door is the screen: the probe re-fires when the anchor
|
|
568
574
|
// changes under a background refetch, and a late answer that moved the step
|
|
569
575
|
// from Review would push an entry the wizard does not own and leave the count
|
|
@@ -1001,6 +1007,7 @@ function SelectionWizardSession({
|
|
|
1001
1007
|
accountId: wizardScope.accountId,
|
|
1002
1008
|
onModeChange: changeMode,
|
|
1003
1009
|
semanticUnavailable,
|
|
1010
|
+
semanticOff,
|
|
1004
1011
|
semanticErrorDetail:
|
|
1005
1012
|
widen.error instanceof Error ? widen.error.message : undefined,
|
|
1006
1013
|
semanticFallbackTaken,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Similar messages" on an instance with semantic search off (#1068).
|
|
3
|
+
*
|
|
4
|
+
* Off is a deployment setting, and the client learns it from the config read
|
|
5
|
+
* every surface already makes. Before this the section rendered nothing at all,
|
|
6
|
+
* which reads as a sender nothing resembles — the one thing an operator who
|
|
7
|
+
* has not turned semantic search on cannot tell from the truth.
|
|
8
|
+
*
|
|
9
|
+
* Drives the real chain: `useIntelligenceData` reads `GET /config`, the state
|
|
10
|
+
* resolver turns `semanticSearchEnabled: false` into the off state, and the
|
|
11
|
+
* real `IntelligencePanel` renders it. Both halves are asserted — the copy the
|
|
12
|
+
* operator acts on, and that no semantic query is sent at all.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { afterEach, describe, it } from "node:test";
|
|
17
|
+
import { IntelligencePanel } from "@remit/ui";
|
|
18
|
+
import { createElement } from "react";
|
|
19
|
+
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
20
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
21
|
+
import {
|
|
22
|
+
makeAccount,
|
|
23
|
+
makeConfig,
|
|
24
|
+
makeThreadMessage,
|
|
25
|
+
} from "@/test-support/fixtures";
|
|
26
|
+
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
27
|
+
import { resolveSimilarState } from "./IntelligencePane";
|
|
28
|
+
|
|
29
|
+
const thread = makeThreadMessage({ messageId: "msg-1" });
|
|
30
|
+
|
|
31
|
+
const SimilarHarness = () => {
|
|
32
|
+
const {
|
|
33
|
+
data,
|
|
34
|
+
isSimilarLoading,
|
|
35
|
+
similarError,
|
|
36
|
+
similarErrorIsFatal,
|
|
37
|
+
semanticEnabled,
|
|
38
|
+
} = useIntelligenceData(thread, "mbx-inbox");
|
|
39
|
+
if (!data) return null;
|
|
40
|
+
return createElement(IntelligencePanel, {
|
|
41
|
+
data,
|
|
42
|
+
similarState: resolveSimilarState({
|
|
43
|
+
similarError,
|
|
44
|
+
similarErrorIsFatal,
|
|
45
|
+
isSimilarLoading,
|
|
46
|
+
semanticEnabled,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const respond =
|
|
52
|
+
(semanticSearchEnabled: boolean) => (call: { path: string }) => {
|
|
53
|
+
if (call.path.endsWith("/config")) {
|
|
54
|
+
return makeConfig([makeAccount({ accountId: "acc-1" })], {
|
|
55
|
+
semanticSearchEnabled,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (call.path.endsWith("/addresses/search")) return { items: [] };
|
|
59
|
+
return { items: [] };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
let harness: DomHarness | undefined;
|
|
63
|
+
let http: HttpMock;
|
|
64
|
+
|
|
65
|
+
const waitFor = async (
|
|
66
|
+
predicate: () => boolean,
|
|
67
|
+
timeoutMs = 2000,
|
|
68
|
+
): Promise<void> => {
|
|
69
|
+
if (!harness) throw new Error("nothing mounted");
|
|
70
|
+
const deadline = Date.now() + timeoutMs;
|
|
71
|
+
while (!predicate()) {
|
|
72
|
+
if (Date.now() > deadline) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`waitFor: condition never became true within ${timeoutMs}ms`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
await harness.flush();
|
|
78
|
+
await harness.wait(5);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
harness?.close();
|
|
84
|
+
harness = undefined;
|
|
85
|
+
http.restore();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("the Similar messages section on an instance with semantic search off", () => {
|
|
89
|
+
it("states the setting, what turning it on buys, and the command", async () => {
|
|
90
|
+
http = mockFetch(respond(false));
|
|
91
|
+
harness = createDomHarness();
|
|
92
|
+
harness.renderApp(createElement(SimilarHarness));
|
|
93
|
+
|
|
94
|
+
await waitFor(
|
|
95
|
+
() => harness?.text().includes("Semantic search is off") === true,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const text = harness.text();
|
|
99
|
+
assert.match(text, /Semantic search is off on this instance/);
|
|
100
|
+
assert.match(text, /Organize/);
|
|
101
|
+
assert.match(text, /filters/);
|
|
102
|
+
assert.match(text, /remit semantic on/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("sends no semantic query, because an empty answer is the same as no match", async () => {
|
|
106
|
+
http = mockFetch(respond(false));
|
|
107
|
+
harness = createDomHarness();
|
|
108
|
+
harness.renderApp(createElement(SimilarHarness));
|
|
109
|
+
|
|
110
|
+
await waitFor(
|
|
111
|
+
() => harness?.text().includes("Semantic search is off") === true,
|
|
112
|
+
);
|
|
113
|
+
await harness.wait(30);
|
|
114
|
+
await harness.flush();
|
|
115
|
+
|
|
116
|
+
assert.deepEqual(http.to("/search/semantic"), []);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("says none of it while the instance has semantic search on", async () => {
|
|
120
|
+
http = mockFetch(respond(true));
|
|
121
|
+
harness = createDomHarness();
|
|
122
|
+
harness.renderApp(createElement(SimilarHarness));
|
|
123
|
+
|
|
124
|
+
await waitFor(() => http.to("/search/semantic").length > 0);
|
|
125
|
+
|
|
126
|
+
assert.doesNotMatch(harness.text(), /Semantic search is off/);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import React, { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import type { CalendarFeedState } from "@/hooks/calendar/useCalendarFeed";
|
|
6
|
+
import { CalendarFeedCard } from "./CalendarFeedCard";
|
|
7
|
+
|
|
8
|
+
// The node test loader transpiles remit-ui's `.tsx` with the classic JSX
|
|
9
|
+
// runtime, which references a global `React`. Vite uses the automatic runtime,
|
|
10
|
+
// so this shim only exists for the SSR test harness.
|
|
11
|
+
(globalThis as { React?: typeof React }).React = React;
|
|
12
|
+
|
|
13
|
+
const CREATED = Date.parse("2026-05-04T09:12:00Z");
|
|
14
|
+
const ROTATED = Date.parse("2026-08-19T16:40:00Z");
|
|
15
|
+
const ADDRESS = "webcal://mail.example.com/feeds/calendar/token-here.ics";
|
|
16
|
+
|
|
17
|
+
const render = (
|
|
18
|
+
state: CalendarFeedState,
|
|
19
|
+
overrides: {
|
|
20
|
+
mintedUrl?: string;
|
|
21
|
+
isBusy?: boolean;
|
|
22
|
+
actionError?: unknown;
|
|
23
|
+
} = {},
|
|
24
|
+
) =>
|
|
25
|
+
renderToString(
|
|
26
|
+
createElement(CalendarFeedCard, {
|
|
27
|
+
calendarName: "Work",
|
|
28
|
+
state,
|
|
29
|
+
mintedUrl: overrides.mintedUrl ?? "",
|
|
30
|
+
isBusy: overrides.isBusy ?? false,
|
|
31
|
+
actionError: overrides.actionError,
|
|
32
|
+
onMint: () => undefined,
|
|
33
|
+
onRevoke: () => undefined,
|
|
34
|
+
onDismissMinted: () => undefined,
|
|
35
|
+
onRetry: () => undefined,
|
|
36
|
+
}) as never,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
describe("CalendarFeedCard", () => {
|
|
40
|
+
it("names the calendar in every state", () => {
|
|
41
|
+
assert.match(render({ status: "loading" }), /Work/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("offers to create an address when the calendar is not shared", () => {
|
|
45
|
+
const html = render({ status: "absent" });
|
|
46
|
+
assert.match(html, /Create subscription address/);
|
|
47
|
+
assert.doesNotMatch(html, /Stop sharing/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("says the address is the credential before one exists", () => {
|
|
51
|
+
assert.match(
|
|
52
|
+
render({ status: "absent" }),
|
|
53
|
+
/the address is the credential/i,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("shows when a shared address was created, and offers both takebacks", () => {
|
|
58
|
+
const html = render({
|
|
59
|
+
status: "active",
|
|
60
|
+
createdAt: CREATED,
|
|
61
|
+
rotatedAt: 0,
|
|
62
|
+
});
|
|
63
|
+
assert.match(html, /shared/);
|
|
64
|
+
assert.match(html, /Address created/);
|
|
65
|
+
assert.doesNotMatch(html, /last replaced/);
|
|
66
|
+
assert.match(html, /Replace address/);
|
|
67
|
+
assert.match(html, /Stop sharing/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("names the last replacement once the address has been rotated", () => {
|
|
71
|
+
const html = render({
|
|
72
|
+
status: "active",
|
|
73
|
+
createdAt: CREATED,
|
|
74
|
+
rotatedAt: ROTATED,
|
|
75
|
+
});
|
|
76
|
+
assert.match(html, /last replaced/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The whole point of the state: a shared calendar shows no address, because
|
|
81
|
+
* only a hash of it is stored. A card that could re-display it would mean the
|
|
82
|
+
* server had kept the secret.
|
|
83
|
+
*/
|
|
84
|
+
it("never shows an address for a feed it merely knows exists", () => {
|
|
85
|
+
const html = render({
|
|
86
|
+
status: "active",
|
|
87
|
+
createdAt: CREATED,
|
|
88
|
+
rotatedAt: 0,
|
|
89
|
+
});
|
|
90
|
+
assert.doesNotMatch(html, /webcal:\/\//);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("shows a minted address once, with a copy control and both warnings", () => {
|
|
94
|
+
const html = render(
|
|
95
|
+
{ status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
96
|
+
{ mintedUrl: ADDRESS },
|
|
97
|
+
);
|
|
98
|
+
assert.match(html, /webcal:\/\/mail\.example\.com/);
|
|
99
|
+
assert.match(html, /Copy address/);
|
|
100
|
+
assert.match(html, /shown once and cannot be read back/);
|
|
101
|
+
assert.match(html, /Anyone holding this address can read every event/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("states a refused write where the control is, rather than going quiet", () => {
|
|
105
|
+
const html = render(
|
|
106
|
+
{ status: "absent" },
|
|
107
|
+
{ actionError: new Error("Calendar not found") },
|
|
108
|
+
);
|
|
109
|
+
assert.match(html, /was not changed/);
|
|
110
|
+
assert.match(html, /Calendar not found/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A read the server refused is not "not shared". Drawing the create button
|
|
115
|
+
* for it would tell the reader their calendar is private while it may be
|
|
116
|
+
* subscribed to right now.
|
|
117
|
+
*/
|
|
118
|
+
it("keeps a failed read distinct from a calendar that is not shared", () => {
|
|
119
|
+
const html = render({
|
|
120
|
+
status: "unreadable",
|
|
121
|
+
error: new Error("Service unavailable"),
|
|
122
|
+
});
|
|
123
|
+
assert.match(html, /Couldn.{0,6}t read whether Work is shared/);
|
|
124
|
+
assert.match(html, /Service unavailable/);
|
|
125
|
+
assert.doesNotMatch(html, /Create subscription address/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("disables the create control while a write is in flight", () => {
|
|
129
|
+
const html = render({ status: "absent" }, { isBusy: true });
|
|
130
|
+
assert.match(html, /disabled/);
|
|
131
|
+
assert.match(html, /Creating…/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The mint's invalidate() re-reads the feed, and that read passes back
|
|
136
|
+
* through "loading" before it settles as "active". A skeleton drawn over
|
|
137
|
+
* the address already on screen carries a name that is a substring of the
|
|
138
|
+
* address input's own label, so the two collide under one accessible name.
|
|
139
|
+
*/
|
|
140
|
+
it("does not draw the loading skeleton over an address just minted", () => {
|
|
141
|
+
const html = render({ status: "loading" }, { mintedUrl: ADDRESS });
|
|
142
|
+
assert.match(html, /webcal:\/\/mail\.example\.com/);
|
|
143
|
+
assert.doesNotMatch(html, /Loading the subscription address/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("does not draw a read failure over an address just minted", () => {
|
|
147
|
+
const html = render(
|
|
148
|
+
{ status: "unreadable", error: new Error("Service unavailable") },
|
|
149
|
+
{ mintedUrl: ADDRESS },
|
|
150
|
+
);
|
|
151
|
+
assert.match(html, /webcal:\/\/mail\.example\.com/);
|
|
152
|
+
assert.doesNotMatch(html, /Couldn.{0,6}t read whether/);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { CalendarFeedCard } from "./CalendarFeedCard";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Settings › Calendars: the per-calendar subscription address (#1067). Every
|
|
6
|
+
* state the server can put the control in, including the one moment the address
|
|
7
|
+
* is legible and the two confirmations that take it away.
|
|
8
|
+
*/
|
|
9
|
+
const meta: Meta<typeof CalendarFeedCard> = {
|
|
10
|
+
title: "Flows/Settings Calendars/Subscription address",
|
|
11
|
+
component: CalendarFeedCard,
|
|
12
|
+
parameters: { layout: "padded" },
|
|
13
|
+
args: {
|
|
14
|
+
calendarName: "Work",
|
|
15
|
+
mintedUrl: "",
|
|
16
|
+
isBusy: false,
|
|
17
|
+
actionError: undefined,
|
|
18
|
+
onMint: () => undefined,
|
|
19
|
+
onRevoke: () => undefined,
|
|
20
|
+
onDismissMinted: () => undefined,
|
|
21
|
+
onRetry: () => undefined,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
export default meta;
|
|
25
|
+
|
|
26
|
+
type Story = StoryObj<typeof CalendarFeedCard>;
|
|
27
|
+
|
|
28
|
+
const CREATED = Date.parse("2026-05-04T09:12:00Z");
|
|
29
|
+
const ROTATED = Date.parse("2026-08-19T16:40:00Z");
|
|
30
|
+
|
|
31
|
+
const ADDRESS =
|
|
32
|
+
"webcal://mail.example.com/feeds/calendar/9Xq2mB7tK1vHs4dLpZ0rY6wJfN3aC8eQuIoT5gRbVkE.ics";
|
|
33
|
+
|
|
34
|
+
/** The read is still out — no control has an answer to draw yet. */
|
|
35
|
+
export const Loading: Story = {
|
|
36
|
+
args: { state: { status: "loading" } },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Not shared. The one control creates the address, and says what it is. */
|
|
40
|
+
export const NotShared: Story = {
|
|
41
|
+
args: { state: { status: "absent" } },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The create is in flight. */
|
|
45
|
+
export const Creating: Story = {
|
|
46
|
+
args: { state: { status: "absent" }, isBusy: true },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Shared, on an address that has never been replaced. */
|
|
50
|
+
export const Shared: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Shared, on the dark theme. */
|
|
57
|
+
export const SharedDark: Story = {
|
|
58
|
+
name: "Shared (dark)",
|
|
59
|
+
parameters: { theme: "dark" },
|
|
60
|
+
args: {
|
|
61
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Shared on an address that has been replaced since it was first created. */
|
|
66
|
+
export const SharedAfterRotation: Story = {
|
|
67
|
+
args: {
|
|
68
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: ROTATED },
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The one moment the address is readable: straight after the write that minted
|
|
74
|
+
* it. It is not stored in the clear, so nothing can show it again.
|
|
75
|
+
*/
|
|
76
|
+
export const AddressShownOnce: Story = {
|
|
77
|
+
args: {
|
|
78
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
79
|
+
mintedUrl: ADDRESS,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The same, on the dark theme. */
|
|
84
|
+
export const AddressShownOnceDark: Story = {
|
|
85
|
+
name: "Address shown once (dark)",
|
|
86
|
+
parameters: { theme: "dark" },
|
|
87
|
+
args: {
|
|
88
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
89
|
+
mintedUrl: ADDRESS,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Replacing the address warns that the current one stops working. */
|
|
94
|
+
export const RotateConfirm: Story = {
|
|
95
|
+
args: {
|
|
96
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
97
|
+
},
|
|
98
|
+
play: async ({ canvasElement }) => {
|
|
99
|
+
const buttons = [...canvasElement.querySelectorAll("button")];
|
|
100
|
+
buttons.find((button) => button.textContent === "Replace address")?.click();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Revoking says the address stops working immediately. */
|
|
105
|
+
export const RevokeConfirm: Story = {
|
|
106
|
+
args: {
|
|
107
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
108
|
+
},
|
|
109
|
+
play: async ({ canvasElement }) => {
|
|
110
|
+
const buttons = [...canvasElement.querySelectorAll("button")];
|
|
111
|
+
buttons.find((button) => button.textContent === "Stop sharing")?.click();
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** The write was refused. The card says so where the button is. */
|
|
116
|
+
export const WriteRefused: Story = {
|
|
117
|
+
args: {
|
|
118
|
+
state: { status: "absent" },
|
|
119
|
+
actionError: new Error("Calendar not found"),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The read itself failed. Not drawn as "not shared": a calendar the server
|
|
125
|
+
* would not answer for may well be shared with somebody right now.
|
|
126
|
+
*/
|
|
127
|
+
export const Unreadable: Story = {
|
|
128
|
+
args: {
|
|
129
|
+
state: {
|
|
130
|
+
status: "unreadable",
|
|
131
|
+
error: new Error("Service unavailable"),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|