@remit/ui 0.0.44 → 0.0.46
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/filter-rule-editor.tsx +8 -0
- package/src/components/mobile-search-view.tsx +4 -0
- package/src/components/search-conversion-notice.stories.tsx +41 -0
- package/src/components/search-conversion-notice.tsx +67 -0
- package/src/components/search-conversion.render.test.ts +77 -0
- package/src/components/search-conversion.ts +59 -0
- package/src/components/search-results.render.test.ts +36 -0
- package/src/components/search-results.stories.tsx +25 -0
- package/src/components/search-results.tsx +53 -1
- package/src/components/self-update-section.tsx +34 -0
- package/src/components/self-update.render.test.ts +19 -0
- package/src/components/self-update.stories.tsx +10 -0
- package/src/components/self-update.ts +6 -0
- package/src/index.ts +11 -0
package/package.json
CHANGED
|
@@ -37,6 +37,12 @@ export interface FilterRuleEditorProps {
|
|
|
37
37
|
rule: FilterRule;
|
|
38
38
|
folders: FolderOption[];
|
|
39
39
|
preview: PreviewCount;
|
|
40
|
+
/**
|
|
41
|
+
* Content rendered above the clause chips — the filter-from-search conversion
|
|
42
|
+
* notice (RFC 038 D5) uses it to state what the search carried that the rule
|
|
43
|
+
* cannot. Absent on the Organize surface, which converts nothing.
|
|
44
|
+
*/
|
|
45
|
+
notice?: ReactNode;
|
|
40
46
|
/**
|
|
41
47
|
* Whether the deployment can serve the semantic widen (RFC 038 D4). When
|
|
42
48
|
* false the "…and anything similar" chip is never offered — an already-present
|
|
@@ -93,6 +99,7 @@ export function FilterRuleEditor({
|
|
|
93
99
|
rule,
|
|
94
100
|
folders,
|
|
95
101
|
preview,
|
|
102
|
+
notice,
|
|
96
103
|
semanticAvailable = false,
|
|
97
104
|
clauseFields,
|
|
98
105
|
lifecycleLocked = false,
|
|
@@ -131,6 +138,7 @@ export function FilterRuleEditor({
|
|
|
131
138
|
</div>
|
|
132
139
|
|
|
133
140
|
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">
|
|
141
|
+
{notice}
|
|
134
142
|
<section className="space-y-3">
|
|
135
143
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
136
144
|
{rule.clauses.map((clause, index) => (
|
|
@@ -46,6 +46,8 @@ export interface MobileSearchViewProps {
|
|
|
46
46
|
onRemoveChip?: (id: string) => void;
|
|
47
47
|
/** What the search covers; see `SearchResultsProps`. Defaults to global. */
|
|
48
48
|
scope?: SearchScope;
|
|
49
|
+
/** "Make this a filter" affordance; see `SearchResultsProps`. */
|
|
50
|
+
makeFilter?: { onClick: () => void; disabledReason?: string };
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|
|
@@ -77,6 +79,7 @@ export function MobileSearchView({
|
|
|
77
79
|
chips,
|
|
78
80
|
onRemoveChip,
|
|
79
81
|
scope,
|
|
82
|
+
makeFilter,
|
|
80
83
|
}: MobileSearchViewProps) {
|
|
81
84
|
const body = (
|
|
82
85
|
<SearchResults
|
|
@@ -88,6 +91,7 @@ export function MobileSearchView({
|
|
|
88
91
|
onSelectResult={onSelectResult}
|
|
89
92
|
tokens={tokens}
|
|
90
93
|
scope={scope}
|
|
94
|
+
makeFilter={makeFilter}
|
|
91
95
|
/>
|
|
92
96
|
);
|
|
93
97
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { SearchConversionNoticeView } from "./search-conversion-notice.js";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "Filters/Search conversion notice",
|
|
6
|
+
component: SearchConversionNoticeView,
|
|
7
|
+
parameters: { layout: "centered" },
|
|
8
|
+
} satisfies Meta<typeof SearchConversionNoticeView>;
|
|
9
|
+
export default meta;
|
|
10
|
+
|
|
11
|
+
type Story = StoryObj<typeof meta>;
|
|
12
|
+
|
|
13
|
+
/** A folder-scoped search: the filter cannot be pinned to the folder. */
|
|
14
|
+
export const FolderScopedOut: Story = {
|
|
15
|
+
args: { notice: { scopedOutFolder: "Archive" } },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Attribute facets that are not filter conditions, named rather than dropped silently. */
|
|
19
|
+
export const DroppedFacets: Story = {
|
|
20
|
+
args: {
|
|
21
|
+
notice: {
|
|
22
|
+
droppedFacets: ["Has attachment", "Unread", "Before 2026-01-01"],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A deployment that cannot match by meaning — literal words kept, no similarity claim. */
|
|
28
|
+
export const DroppedSemantic: Story = {
|
|
29
|
+
args: { notice: { droppedSemantic: true } },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Everything a rich search carried that the filter cannot. */
|
|
33
|
+
export const Everything: Story = {
|
|
34
|
+
args: {
|
|
35
|
+
notice: {
|
|
36
|
+
scopedOutFolder: "Archive",
|
|
37
|
+
droppedFacets: ["Has attachment", "Before 2026-01-01"],
|
|
38
|
+
droppedSemantic: true,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { FolderInput, Info, Sparkles } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
DROPPED_SEMANTIC_COPY,
|
|
4
|
+
droppedFacetsCopy,
|
|
5
|
+
hasConversionNotice,
|
|
6
|
+
type SearchConversionNotice,
|
|
7
|
+
scopedOutCopy,
|
|
8
|
+
} from "./search-conversion.js";
|
|
9
|
+
|
|
10
|
+
export interface SearchConversionNoticeProps {
|
|
11
|
+
notice: SearchConversionNotice;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function NoticeRow({
|
|
15
|
+
icon,
|
|
16
|
+
children,
|
|
17
|
+
}: {
|
|
18
|
+
icon: React.ReactNode;
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
}) {
|
|
21
|
+
return (
|
|
22
|
+
<li className="flex items-start gap-2">
|
|
23
|
+
<span className="mt-0.5 shrink-0 text-fg-subtle" aria-hidden="true">
|
|
24
|
+
{icon}
|
|
25
|
+
</span>
|
|
26
|
+
<span>{children}</span>
|
|
27
|
+
</li>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What converting a search to a filter left behind (RFC 038 D5). Rendered above
|
|
33
|
+
* the clause chips so the user sees, before touching the rule, every part of the
|
|
34
|
+
* search the filter cannot carry: a folder scope, non-clause facets, and the
|
|
35
|
+
* semantic reach a literal clause loses. Nothing to state renders nothing.
|
|
36
|
+
* Presentational and prop-driven.
|
|
37
|
+
*/
|
|
38
|
+
export function SearchConversionNoticeView({
|
|
39
|
+
notice,
|
|
40
|
+
}: SearchConversionNoticeProps) {
|
|
41
|
+
if (!hasConversionNotice(notice)) return null;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="rounded-md border border-line bg-surface-sunken px-3 py-2.5">
|
|
45
|
+
<p className="text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
|
|
46
|
+
From your search
|
|
47
|
+
</p>
|
|
48
|
+
<ul className="mt-1.5 space-y-1.5 text-xs text-fg-muted">
|
|
49
|
+
{notice.scopedOutFolder !== undefined && (
|
|
50
|
+
<NoticeRow icon={<FolderInput className="size-3.5" />}>
|
|
51
|
+
{scopedOutCopy(notice.scopedOutFolder)}
|
|
52
|
+
</NoticeRow>
|
|
53
|
+
)}
|
|
54
|
+
{notice.droppedFacets && notice.droppedFacets.length > 0 && (
|
|
55
|
+
<NoticeRow icon={<Info className="size-3.5" />}>
|
|
56
|
+
{droppedFacetsCopy(notice.droppedFacets)}
|
|
57
|
+
</NoticeRow>
|
|
58
|
+
)}
|
|
59
|
+
{notice.droppedSemantic && (
|
|
60
|
+
<NoticeRow icon={<Sparkles className="size-3.5" />}>
|
|
61
|
+
{DROPPED_SEMANTIC_COPY}
|
|
62
|
+
</NoticeRow>
|
|
63
|
+
)}
|
|
64
|
+
</ul>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import {
|
|
6
|
+
DROPPED_SEMANTIC_COPY,
|
|
7
|
+
droppedFacetsCopy,
|
|
8
|
+
hasConversionNotice,
|
|
9
|
+
scopedOutCopy,
|
|
10
|
+
} from "./search-conversion.js";
|
|
11
|
+
import { SearchConversionNoticeView } from "./search-conversion-notice.js";
|
|
12
|
+
|
|
13
|
+
/** SSR splits interpolations with comment markers; sentences read across them. */
|
|
14
|
+
const render = (element: Parameters<typeof renderToString>[0]) =>
|
|
15
|
+
renderToString(element).replaceAll("<!-- -->", "");
|
|
16
|
+
|
|
17
|
+
describe("search-conversion copy", () => {
|
|
18
|
+
it("names the folder a scoped search is kept out of the filter for", () => {
|
|
19
|
+
assert.match(scopedOutCopy("Archive"), /limited to Archive/);
|
|
20
|
+
assert.match(scopedOutCopy("Archive"), /any folder/i);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("names one dropped facet in the singular", () => {
|
|
24
|
+
const copy = droppedFacetsCopy(["Has attachment"]);
|
|
25
|
+
assert.match(copy, /Has attachment isn't a filter condition/);
|
|
26
|
+
assert.match(copy, /left out/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("joins several dropped facets and reads plural", () => {
|
|
30
|
+
const copy = droppedFacetsCopy(["Has attachment", "Unread", "Before 2026"]);
|
|
31
|
+
assert.match(copy, /Has attachment, Unread and Before 2026 aren't/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("states the filter is literal-only and the reach is not carried", () => {
|
|
35
|
+
assert.match(DROPPED_SEMANTIC_COPY, /matches these words literally/i);
|
|
36
|
+
// Never claims the filter itself matches by meaning.
|
|
37
|
+
assert.match(DROPPED_SEMANTIC_COPY, /a filter can't carry that/i);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("has nothing to say when nothing was dropped", () => {
|
|
41
|
+
assert.equal(hasConversionNotice({}), false);
|
|
42
|
+
assert.equal(hasConversionNotice({ droppedSemantic: false }), false);
|
|
43
|
+
assert.equal(hasConversionNotice({ droppedFacets: [] }), false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("has something to say for any single drop", () => {
|
|
47
|
+
assert.equal(hasConversionNotice({ scopedOutFolder: "Archive" }), true);
|
|
48
|
+
assert.equal(hasConversionNotice({ droppedFacets: ["Unread"] }), true);
|
|
49
|
+
assert.equal(hasConversionNotice({ droppedSemantic: true }), true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("SearchConversionNoticeView", () => {
|
|
54
|
+
it("renders every drop the conversion recorded", () => {
|
|
55
|
+
const html = render(
|
|
56
|
+
createElement(SearchConversionNoticeView, {
|
|
57
|
+
notice: {
|
|
58
|
+
scopedOutFolder: "Archive",
|
|
59
|
+
droppedFacets: ["Has attachment"],
|
|
60
|
+
droppedSemantic: true,
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
assert.match(html, /From your search/);
|
|
65
|
+
assert.match(html, /limited to Archive/);
|
|
66
|
+
assert.match(html, /Has attachment/);
|
|
67
|
+
assert.match(html, /left out/);
|
|
68
|
+
assert.match(html, /matches these words literally/i);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("renders nothing when there is nothing to state", () => {
|
|
72
|
+
const html = render(
|
|
73
|
+
createElement(SearchConversionNoticeView, { notice: {} }),
|
|
74
|
+
);
|
|
75
|
+
assert.equal(html, "");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The honest copy for turning a search into a filter (RFC 038 D5).
|
|
3
|
+
*
|
|
4
|
+
* A search carries reach a filter's clauses cannot: a folder it was limited to,
|
|
5
|
+
* attribute facets that are not clauses, and — where the deployment can embed a
|
|
6
|
+
* query at request time — semantic similarity. The conversion never drops any of
|
|
7
|
+
* that silently. This module is the vocabulary the conversion notice renders; the
|
|
8
|
+
* web-client computes which parts apply and feeds the notice.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface SearchConversionNotice {
|
|
12
|
+
/** The folder the search was limited to, kept OUT of the filter. */
|
|
13
|
+
scopedOutFolder?: string;
|
|
14
|
+
/** Facet labels with no clause equivalent (e.g. "Has attachment", "Before 2026-01-01"). */
|
|
15
|
+
droppedFacets?: string[];
|
|
16
|
+
/**
|
|
17
|
+
* Free text was kept as a literal `HasWords` clause, and the search had
|
|
18
|
+
* surfaced similar mail by meaning that the literal filter cannot reproduce
|
|
19
|
+
* (RFC 038 D5) — the "similar mail" reach is not part of the filter.
|
|
20
|
+
*/
|
|
21
|
+
droppedSemantic?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** True when the conversion left something behind worth stating. */
|
|
25
|
+
export function hasConversionNotice(notice: SearchConversionNotice): boolean {
|
|
26
|
+
return (
|
|
27
|
+
notice.scopedOutFolder !== undefined ||
|
|
28
|
+
(notice.droppedFacets?.length ?? 0) > 0 ||
|
|
29
|
+
notice.droppedSemantic === true
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Never silently turn a folder-scoped search into a filter that matches everywhere. */
|
|
34
|
+
export function scopedOutCopy(folder: string): string {
|
|
35
|
+
return `Your search was limited to ${folder}. This filter matches these messages in any folder — it can't be pinned to one.`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const joinFacets = (facets: string[]): string => {
|
|
39
|
+
if (facets.length === 1) return facets[0];
|
|
40
|
+
if (facets.length === 2) return `${facets[0]} and ${facets[1]}`;
|
|
41
|
+
return `${facets.slice(0, -1).join(", ")} and ${facets[facets.length - 1]}`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Name each attribute facet a filter cannot express, rather than dropping it unremarked. */
|
|
45
|
+
export function droppedFacetsCopy(facets: string[]): string {
|
|
46
|
+
const noun = facets.length === 1 ? "a filter condition" : "filter conditions";
|
|
47
|
+
const verb = facets.length === 1 ? "isn't" : "aren't";
|
|
48
|
+
return `${joinFacets(facets)} ${verb} ${noun}, so ${
|
|
49
|
+
facets.length === 1 ? "it was" : "they were"
|
|
50
|
+
} left out — the filter still matches everything else you searched for.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* States the filter is literal-only, so the search's similar-mail reach is not
|
|
55
|
+
* carried (RFC 038 D5). Shown only where that reach existed — a capable search
|
|
56
|
+
* that surfaced similar mail — so it never claims a reach the filter had.
|
|
57
|
+
*/
|
|
58
|
+
export const DROPPED_SEMANTIC_COPY =
|
|
59
|
+
"The filter matches these words literally. Your search also found similar mail by meaning — a filter can't carry that, so it won't catch mail that means the same thing without these words.";
|
|
@@ -86,6 +86,42 @@ describe("SearchResults", () => {
|
|
|
86
86
|
);
|
|
87
87
|
assert.doesNotMatch(html, /Remove filter/);
|
|
88
88
|
});
|
|
89
|
+
|
|
90
|
+
it("offers 'Make this a filter' above active results", () => {
|
|
91
|
+
const html = renderToString(
|
|
92
|
+
createElement(SearchResults, {
|
|
93
|
+
value: "invoice",
|
|
94
|
+
sections,
|
|
95
|
+
makeFilter: { onClick: noop },
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
assert.match(html, /Make this a filter/);
|
|
99
|
+
assert.doesNotMatch(html, /disabled=""/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("disables the filter offer with its reason when nothing converts", () => {
|
|
103
|
+
const html = renderToString(
|
|
104
|
+
createElement(SearchResults, {
|
|
105
|
+
value: "has:attachment",
|
|
106
|
+
sections: [{ id: "results", label: "Results", results: [] }],
|
|
107
|
+
makeFilter: { onClick: noop, disabledReason: "Add a sender or words" },
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
assert.match(html, /Make this a filter/);
|
|
111
|
+
assert.match(html, /disabled/);
|
|
112
|
+
assert.match(html, /Add a sender or words/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("never offers the filter on the empty-query recent-searches view", () => {
|
|
116
|
+
const html = renderToString(
|
|
117
|
+
createElement(SearchResults, {
|
|
118
|
+
value: "",
|
|
119
|
+
recentSearches: ["invoice"],
|
|
120
|
+
makeFilter: { onClick: noop },
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
assert.doesNotMatch(html, /Make this a filter/);
|
|
124
|
+
});
|
|
89
125
|
});
|
|
90
126
|
|
|
91
127
|
const spamResult: SearchResult = {
|
|
@@ -167,6 +167,31 @@ export const NoResults: Story = {
|
|
|
167
167
|
render: () => <Harness value="asdfqwer" sections={emptySections} />,
|
|
168
168
|
};
|
|
169
169
|
|
|
170
|
+
/** "Make this a filter" offered above active results (RFC 038 D5). */
|
|
171
|
+
export const WithMakeFilter: Story = {
|
|
172
|
+
render: () => (
|
|
173
|
+
<Harness
|
|
174
|
+
value="invoice"
|
|
175
|
+
sections={resultSections}
|
|
176
|
+
makeFilter={{ onClick: () => {} }}
|
|
177
|
+
/>
|
|
178
|
+
),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** The filter offer disabled — a search of only non-clause facets has nothing to convert. */
|
|
182
|
+
export const MakeFilterDisabled: Story = {
|
|
183
|
+
render: () => (
|
|
184
|
+
<Harness
|
|
185
|
+
value="has:attachment"
|
|
186
|
+
sections={resultSections}
|
|
187
|
+
makeFilter={{
|
|
188
|
+
onClick: () => {},
|
|
189
|
+
disabledReason: "Add a sender or words to filter on",
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
),
|
|
193
|
+
};
|
|
194
|
+
|
|
170
195
|
/** Results still loading. */
|
|
171
196
|
export const Loading: Story = {
|
|
172
197
|
render: () => <Harness value="invoice" loading />,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChevronDown, Clock } from "lucide-react";
|
|
1
|
+
import { ChevronDown, Clock, Filter } from "lucide-react";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
4
|
import type { FolderRole } from "./folder-role.js";
|
|
@@ -84,6 +84,48 @@ export interface SearchResultsProps {
|
|
|
84
84
|
tokens?: { label: string; onRemove: () => void }[];
|
|
85
85
|
/** What the search covers. Defaults to the unscoped, global search. */
|
|
86
86
|
scope?: SearchScope;
|
|
87
|
+
/**
|
|
88
|
+
* "Make this a filter" (RFC 038 D5) — offered above the results while a query
|
|
89
|
+
* is active, converting the search to a pre-filled rule. Omit to drop the
|
|
90
|
+
* affordance; a `disabledReason` renders it inert with the reason (a search of
|
|
91
|
+
* only non-clause facets has nothing to convert).
|
|
92
|
+
*/
|
|
93
|
+
makeFilter?: { onClick: () => void; disabledReason?: string };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** "Make this a filter" — the conversion entry offered above active search results. */
|
|
97
|
+
function MakeFilterButton({
|
|
98
|
+
onClick,
|
|
99
|
+
disabledReason,
|
|
100
|
+
}: {
|
|
101
|
+
onClick: () => void;
|
|
102
|
+
disabledReason?: string;
|
|
103
|
+
}) {
|
|
104
|
+
const disabled = disabledReason !== undefined;
|
|
105
|
+
return (
|
|
106
|
+
<div className="border-b border-line px-row-inset py-1.5">
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={onClick}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
title={disabledReason}
|
|
112
|
+
className={cn(
|
|
113
|
+
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs font-medium transition-colors",
|
|
114
|
+
disabled
|
|
115
|
+
? "cursor-not-allowed text-fg-subtle"
|
|
116
|
+
: "text-accent hover:bg-surface-sunken",
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
<Filter className="size-3.5 shrink-0" aria-hidden="true" />
|
|
120
|
+
<span>Make this a filter</span>
|
|
121
|
+
{disabled && (
|
|
122
|
+
<span className="ml-auto truncate text-2xs font-normal text-fg-subtle">
|
|
123
|
+
{disabledReason}
|
|
124
|
+
</span>
|
|
125
|
+
)}
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
87
129
|
}
|
|
88
130
|
|
|
89
131
|
/**
|
|
@@ -195,6 +237,7 @@ export function SearchResults({
|
|
|
195
237
|
onSelectResult,
|
|
196
238
|
tokens,
|
|
197
239
|
scope = GLOBAL_SCOPE,
|
|
240
|
+
makeFilter,
|
|
198
241
|
}: SearchResultsProps) {
|
|
199
242
|
const hasQuery = value.trim().length > 0;
|
|
200
243
|
|
|
@@ -231,10 +274,17 @@ export function SearchResults({
|
|
|
231
274
|
const chips = tokens && tokens.length > 0 && (
|
|
232
275
|
<SearchTokenChips tokens={tokens} />
|
|
233
276
|
);
|
|
277
|
+
const filterAction = makeFilter && (
|
|
278
|
+
<MakeFilterButton
|
|
279
|
+
onClick={makeFilter.onClick}
|
|
280
|
+
disabledReason={makeFilter.disabledReason}
|
|
281
|
+
/>
|
|
282
|
+
);
|
|
234
283
|
|
|
235
284
|
if (loading) {
|
|
236
285
|
return (
|
|
237
286
|
<div className="flex flex-col">
|
|
287
|
+
{filterAction}
|
|
238
288
|
{chips}
|
|
239
289
|
<div className="flex flex-col gap-3 px-row-inset py-4">
|
|
240
290
|
{[0, 1, 2, 3].map((row) => (
|
|
@@ -286,6 +336,7 @@ export function SearchResults({
|
|
|
286
336
|
if (!hasResults) {
|
|
287
337
|
return (
|
|
288
338
|
<div className="flex flex-col">
|
|
339
|
+
{filterAction}
|
|
289
340
|
{chips}
|
|
290
341
|
{spamOffer}
|
|
291
342
|
<div className="px-row-inset py-10 text-center">
|
|
@@ -302,6 +353,7 @@ export function SearchResults({
|
|
|
302
353
|
|
|
303
354
|
return (
|
|
304
355
|
<div className="flex flex-col">
|
|
356
|
+
{filterAction}
|
|
305
357
|
{chips}
|
|
306
358
|
{spamOffer}
|
|
307
359
|
{visibleSections
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CheckCircle2,
|
|
3
|
+
Clock,
|
|
3
4
|
CloudOff,
|
|
4
5
|
Download,
|
|
5
6
|
ExternalLink,
|
|
@@ -152,6 +153,39 @@ export function SelfUpdateSection({
|
|
|
152
153
|
</SectionRow>
|
|
153
154
|
);
|
|
154
155
|
|
|
156
|
+
case "neverChecked":
|
|
157
|
+
return (
|
|
158
|
+
<SectionRow>
|
|
159
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
160
|
+
<div className="flex min-w-0 items-start gap-2">
|
|
161
|
+
<Clock
|
|
162
|
+
className="mt-0.5 size-4 shrink-0 text-fg-subtle"
|
|
163
|
+
aria-hidden
|
|
164
|
+
/>
|
|
165
|
+
<p className="text-sm text-fg-muted">
|
|
166
|
+
No update check has run yet. Remit checks for updates on its
|
|
167
|
+
own in the background.
|
|
168
|
+
{state.lastCheckedAt !== undefined && (
|
|
169
|
+
<span className="text-fg-subtle">
|
|
170
|
+
{" "}
|
|
171
|
+
Last checked{" "}
|
|
172
|
+
{formatRelativeCheck(state.lastCheckedAt, now)}.
|
|
173
|
+
</span>
|
|
174
|
+
)}
|
|
175
|
+
</p>
|
|
176
|
+
</div>
|
|
177
|
+
<Button
|
|
178
|
+
variant="secondary"
|
|
179
|
+
size="sm"
|
|
180
|
+
className="shrink-0"
|
|
181
|
+
onClick={handleCheck}
|
|
182
|
+
>
|
|
183
|
+
Check now
|
|
184
|
+
</Button>
|
|
185
|
+
</div>
|
|
186
|
+
</SectionRow>
|
|
187
|
+
);
|
|
188
|
+
|
|
155
189
|
case "checkFailed":
|
|
156
190
|
return (
|
|
157
191
|
<SectionRow>
|
|
@@ -165,6 +165,25 @@ describe("SelfUpdateSection", () => {
|
|
|
165
165
|
assert.doesNotMatch(html, /disabled=/);
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
+
it("states a never-checked instance without a spinner or an alert", () => {
|
|
169
|
+
// The version is the literal the backend emits before any check has run —
|
|
170
|
+
// no state file, so no authoritative version yet.
|
|
171
|
+
const html = section({ status: "neverChecked", version: "unknown" });
|
|
172
|
+
assert.match(html, /No update check has run yet/);
|
|
173
|
+
assert.match(html, /Check now/);
|
|
174
|
+
assert.doesNotMatch(html, /animate-spin/);
|
|
175
|
+
assert.doesNotMatch(html, /role="alert"/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("shows the last-checked time on a never-checked state when one is known", () => {
|
|
179
|
+
const html = section({
|
|
180
|
+
status: "neverChecked",
|
|
181
|
+
version: "unknown",
|
|
182
|
+
lastCheckedAt: NOW - 3_600_000,
|
|
183
|
+
});
|
|
184
|
+
assert.match(html, /Last checked/);
|
|
185
|
+
});
|
|
186
|
+
|
|
168
187
|
it("renders a failed rollback verbatim and never claims a version is running", () => {
|
|
169
188
|
const html = section({
|
|
170
189
|
status: "rollbackFailed",
|
|
@@ -52,6 +52,16 @@ export const Checking: Story = {
|
|
|
52
52
|
args: withState({ status: "checking", version: CURRENT }),
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* A fresh, configured instance before its first automatic check has landed. The
|
|
57
|
+
* honest resting state, not a spinner: it says a check has not run and that Remit
|
|
58
|
+
* checks on its own, and offers a manual check. This is what every install shows
|
|
59
|
+
* until the updater writes its first result.
|
|
60
|
+
*/
|
|
61
|
+
export const NeverChecked: Story = {
|
|
62
|
+
args: withState({ status: "neverChecked", version: "unknown" }),
|
|
63
|
+
};
|
|
64
|
+
|
|
55
65
|
/**
|
|
56
66
|
* An update exists. It is stated — version, date, one line of what changes, a
|
|
57
67
|
* link to the full notes — and then it waits. Nothing here follows the user
|
|
@@ -28,6 +28,12 @@ export type UpdateRunId = string;
|
|
|
28
28
|
export type SelfUpdateState =
|
|
29
29
|
| { status: "upToDate"; version: string; checkedAt: number }
|
|
30
30
|
| { status: "checking"; version: string }
|
|
31
|
+
/**
|
|
32
|
+
* A configured instance whose first automatic check has not landed yet. It is
|
|
33
|
+
* its own resting state, never a spinner: the updater checks on a cadence, and
|
|
34
|
+
* this is the honest account of the gap before the first result arrives.
|
|
35
|
+
*/
|
|
36
|
+
| { status: "neverChecked"; version: string; lastCheckedAt?: number }
|
|
31
37
|
| {
|
|
32
38
|
status: "checkFailed";
|
|
33
39
|
version: string;
|
package/src/index.ts
CHANGED
|
@@ -390,6 +390,17 @@ export {
|
|
|
390
390
|
SearchChipInput,
|
|
391
391
|
type SearchChipInputProps,
|
|
392
392
|
} from "./components/search-chip-input.js";
|
|
393
|
+
export {
|
|
394
|
+
DROPPED_SEMANTIC_COPY,
|
|
395
|
+
droppedFacetsCopy,
|
|
396
|
+
hasConversionNotice,
|
|
397
|
+
type SearchConversionNotice,
|
|
398
|
+
scopedOutCopy,
|
|
399
|
+
} from "./components/search-conversion.js";
|
|
400
|
+
export {
|
|
401
|
+
type SearchConversionNoticeProps,
|
|
402
|
+
SearchConversionNoticeView,
|
|
403
|
+
} from "./components/search-conversion-notice.js";
|
|
393
404
|
export {
|
|
394
405
|
type SearchResult,
|
|
395
406
|
SearchResultRow,
|