@remit/ui 0.0.44 → 0.0.45

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.44",
3
+ "version": "0.0.45",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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
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,