rastack 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/CHANGELOG.md +7 -0
- package/admin/AdminApp.tsx +311 -158
- package/admin/adapters.ts +99 -0
- package/admin/httpEngine.ts +56 -0
- package/admin/index.tsx +91 -39
- package/admin/ui/FilterBar.tsx +231 -0
- package/admin/ui/Popover.tsx +54 -0
- package/admin/ui/Sidebar.tsx +86 -0
- package/admin/ui/icons.tsx +146 -0
- package/admin/ui/styles.ts +398 -0
- package/admin/useAdmin.ts +149 -14
- package/dist/admin.js +395 -17
- package/dist/dev/harness.d.ts +17 -0
- package/dist/dev/harness.js +39 -1
- package/dist/rastack-admin.d.ts +14 -0
- package/dist/rastack-admin.js +128 -0
- package/dist/rastack.d.ts +1 -1
- package/dist/rastack.js +7 -23
- package/dist/wasm/rastack_wasm.js +1 -1
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/package.json +1 -1
- package/src/dev/harness.ts +44 -0
- package/src/rastack-admin.ts +112 -0
- package/src/rastack.ts +8 -28
- package/test/dev.spec.ts +96 -0
- package/wasm/rastack_wasm.js +1 -1
- package/wasm/rastack_wasm_bg.wasm +0 -0
package/admin/adapters.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface AdminField {
|
|
|
28
28
|
primaryKey: boolean;
|
|
29
29
|
maxLength?: number;
|
|
30
30
|
nullable: boolean;
|
|
31
|
+
/** Unique constraint — such fields are identifiers, not filter facets. */
|
|
32
|
+
unique?: boolean;
|
|
31
33
|
/** `{ app, model }` for a foreign key (`type === "fk"`). */
|
|
32
34
|
relation?: { app: string; model: string };
|
|
33
35
|
}
|
|
@@ -53,6 +55,7 @@ interface RawField {
|
|
|
53
55
|
primaryKey?: boolean;
|
|
54
56
|
maxLength?: number;
|
|
55
57
|
null?: boolean;
|
|
58
|
+
unique?: boolean;
|
|
56
59
|
relation?: { app: string; model: string };
|
|
57
60
|
}
|
|
58
61
|
interface RawResource {
|
|
@@ -81,6 +84,7 @@ export function parseManifest(manifest: object | string): AdminResource[] {
|
|
|
81
84
|
primaryKey: f.primaryKey === true,
|
|
82
85
|
maxLength: f.maxLength,
|
|
83
86
|
nullable: f.null === true,
|
|
87
|
+
unique: f.unique === true,
|
|
84
88
|
relation: f.relation,
|
|
85
89
|
})),
|
|
86
90
|
search: r.search ?? [],
|
|
@@ -237,6 +241,101 @@ export function buildWriteBody(
|
|
|
237
241
|
return body;
|
|
238
242
|
}
|
|
239
243
|
|
|
244
|
+
// -- filters (Linear-style changelist filter bar) ---------------------------
|
|
245
|
+
|
|
246
|
+
/** How a filter's values are sourced/rendered. */
|
|
247
|
+
export type FilterKind = "fk" | "bool" | "choice";
|
|
248
|
+
|
|
249
|
+
/** A single selectable filter value. */
|
|
250
|
+
export interface FilterOption {
|
|
251
|
+
value: string;
|
|
252
|
+
label: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A field the changelist can filter on, and how to build its value list. */
|
|
256
|
+
export interface FilterableField {
|
|
257
|
+
name: string;
|
|
258
|
+
label: string;
|
|
259
|
+
kind: FilterKind;
|
|
260
|
+
/** FK target table (`kind === "fk"`). */
|
|
261
|
+
relation?: { app: string; model: string };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Distinct-value cap: above this a `string` field is an identifier, not a facet. */
|
|
265
|
+
export const DISTINCT_CAP = 40;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The fields a changelist offers as filters, mirroring what makes sense as a
|
|
269
|
+
* Django `list_filter`: every foreign key (filter by related row), every boolean
|
|
270
|
+
* (true/false), and every non-unique plain-string field (filter by one of its
|
|
271
|
+
* observed values). Unique strings, numbers, ids and timestamps are excluded —
|
|
272
|
+
* they identify a row rather than group many, so they belong in search, not a
|
|
273
|
+
* facet.
|
|
274
|
+
*/
|
|
275
|
+
export function filterableFields(resource: AdminResource): FilterableField[] {
|
|
276
|
+
const out: FilterableField[] = [];
|
|
277
|
+
for (const f of resource.fields) {
|
|
278
|
+
if (f.primaryKey) continue;
|
|
279
|
+
if (f.type === "fk" && f.relation) {
|
|
280
|
+
out.push({ name: f.name, label: humanizeKey(f.name), kind: "fk", relation: f.relation });
|
|
281
|
+
} else if (f.type === "bool") {
|
|
282
|
+
out.push({ name: f.name, label: humanizeKey(f.name), kind: "bool" });
|
|
283
|
+
} else if (f.type === "string" && !f.unique) {
|
|
284
|
+
out.push({ name: f.name, label: humanizeKey(f.name), kind: "choice" });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The fixed True/False options a boolean filter offers. */
|
|
291
|
+
export function boolFilterOptions(): FilterOption[] {
|
|
292
|
+
return [
|
|
293
|
+
{ value: "true", label: "True" },
|
|
294
|
+
{ value: "false", label: "False" },
|
|
295
|
+
];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Distinct non-empty values of `field` across `rows`, sorted, as filter options.
|
|
300
|
+
* Returns `[]` when the field has more than `cap` distinct values (too
|
|
301
|
+
* high-cardinality to be a useful facet — it should be searched, not filtered).
|
|
302
|
+
*/
|
|
303
|
+
export function distinctValueOptions(
|
|
304
|
+
rows: Record<string, unknown>[],
|
|
305
|
+
field: string,
|
|
306
|
+
cap = DISTINCT_CAP,
|
|
307
|
+
): FilterOption[] {
|
|
308
|
+
const seen = new Set<string>();
|
|
309
|
+
for (const row of rows) {
|
|
310
|
+
const v = row[field];
|
|
311
|
+
if (v === null || v === undefined || v === "") continue;
|
|
312
|
+
seen.add(String(v));
|
|
313
|
+
if (seen.size > cap) return [];
|
|
314
|
+
}
|
|
315
|
+
return [...seen].sort().map((value) => ({ value, label: value }));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The label for an FK filter value: reuse the `<select>` option label heuristic
|
|
320
|
+
* (first non-id string field, else `#id`) against the target table's rows.
|
|
321
|
+
*/
|
|
322
|
+
export function fkFilterOptions(rows: Record<string, unknown>[]): FilterOption[] {
|
|
323
|
+
return rows.map((row) => ({
|
|
324
|
+
value: String(row.id ?? ""),
|
|
325
|
+
label: optionLabel(row),
|
|
326
|
+
}));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Merge active filters into an engine list-query object (field → value). */
|
|
330
|
+
export function withFilters(
|
|
331
|
+
query: Record<string, string>,
|
|
332
|
+
filters: Record<string, string>,
|
|
333
|
+
): Record<string, string> {
|
|
334
|
+
// Filters first, then reserved keys, so a field named `page`/`search` can
|
|
335
|
+
// never shadow pagination — the reserved keys always win.
|
|
336
|
+
return { ...filters, ...query };
|
|
337
|
+
}
|
|
338
|
+
|
|
240
339
|
/** Extract a `{detail}` error message from a non-2xx `handle` body. */
|
|
241
340
|
export function errorDetail(body: unknown): string {
|
|
242
341
|
if (body && typeof body === "object" && "detail" in body) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An HTTP-backed `RastackEngine` — the same `handle(method, path, query, body)`
|
|
3
|
+
* seam the WASM engine exposes, implemented over `fetch` against a live
|
|
4
|
+
* `rastack-server`. This is what lets the *identical* admin UI run two ways:
|
|
5
|
+
* over WebAssembly in `rastack dev` (`client.engine`), or over the real Rust
|
|
6
|
+
* API + Iceberg store in `rastack admin` (this adapter) — the components,
|
|
7
|
+
* hooks and adapters above it never learn which.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { RastackEngine } from "../provider/types";
|
|
11
|
+
|
|
12
|
+
/** Build a same-origin (or `baseUrl`-rooted) HTTP engine. */
|
|
13
|
+
export function createHttpEngine(baseUrl = ""): RastackEngine {
|
|
14
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
15
|
+
|
|
16
|
+
async function handle(
|
|
17
|
+
method: string,
|
|
18
|
+
path: string,
|
|
19
|
+
query: Record<string, string> | undefined,
|
|
20
|
+
body: unknown,
|
|
21
|
+
): Promise<{ status: number; body: any }> {
|
|
22
|
+
const qs =
|
|
23
|
+
query && Object.keys(query).length
|
|
24
|
+
? "?" + new URLSearchParams(query).toString()
|
|
25
|
+
: "";
|
|
26
|
+
const hasBody = body !== undefined && body !== null;
|
|
27
|
+
const res = await fetch(base + path + qs, {
|
|
28
|
+
method,
|
|
29
|
+
headers: hasBody ? { "Content-Type": "application/json" } : undefined,
|
|
30
|
+
body: hasBody ? JSON.stringify(body) : undefined,
|
|
31
|
+
credentials: "same-origin",
|
|
32
|
+
});
|
|
33
|
+
// 204 (delete) and errors may carry no body; parse defensively.
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
let parsed: any = null;
|
|
36
|
+
if (text) {
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(text);
|
|
39
|
+
} catch {
|
|
40
|
+
parsed = text;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { status: res.status, body: parsed };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const unavailable = (op: string) => async (): Promise<never> => {
|
|
47
|
+
throw new Error(`${op} is only available over the local WASM engine.`);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
handle,
|
|
52
|
+
seedFile: unavailable("seedFile"),
|
|
53
|
+
importWarehouse: unavailable("importWarehouse"),
|
|
54
|
+
exportWarehouse: unavailable("exportWarehouse") as RastackEngine["exportWarehouse"],
|
|
55
|
+
};
|
|
56
|
+
}
|
package/admin/index.tsx
CHANGED
|
@@ -1,25 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Entry point for the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Entry point for the admin bundle (`dist/admin.js`). It runs two ways from the
|
|
3
|
+
* *same* build, chosen by a `window.__RASTACK_ADMIN__` config the host page
|
|
4
|
+
* injects (defaulting to local):
|
|
5
|
+
*
|
|
6
|
+
* • `rastack dev` → `mode: "local"` — boots the WASM engine via
|
|
7
|
+
* `<RAStackProvider mode="local">`, seeding from the served `/data/warehouse/`.
|
|
8
|
+
* • `rastack admin` → `mode: "remote"` — talks to the live `rastack-server`
|
|
9
|
+
* over HTTP through an `httpEngine`, against the real Iceberg warehouse.
|
|
10
|
+
*
|
|
11
|
+
* Either way it fetches the compiled schema, themes everything with
|
|
12
|
+
* `<ThemeProvider>` and the design tokens, and renders the manifest-driven
|
|
13
|
+
* `<AdminApp>` — which never learns which engine it got.
|
|
7
14
|
*/
|
|
8
15
|
|
|
9
|
-
import React, { useEffect, useState } from "react";
|
|
16
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
10
17
|
import { createRoot } from "react-dom/client";
|
|
11
|
-
import { RAStackProvider } from "../provider";
|
|
18
|
+
import { RAStackProvider, useRastackClient } from "../provider";
|
|
12
19
|
import type { RastackWasmModule } from "../provider/types";
|
|
20
|
+
import { Spinner } from "../components";
|
|
13
21
|
import { ThemeProvider } from "../theme";
|
|
14
22
|
import { defineTheme } from "../tokens";
|
|
15
23
|
import { AdminApp } from "./AdminApp";
|
|
24
|
+
import { parseManifest, type AdminResource } from "./adapters";
|
|
25
|
+
import { createHttpEngine } from "./httpEngine";
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
27
|
+
interface AdminConfig {
|
|
28
|
+
mode: "local" | "remote";
|
|
29
|
+
/** Remote: base URL the `/api/...` paths are joined onto (default same origin). */
|
|
30
|
+
apiBase?: string;
|
|
31
|
+
/** Where to fetch the compiled `schema.rastack.json`. */
|
|
32
|
+
schemaUrl?: string;
|
|
33
|
+
/** Local: base URL of the committed Iceberg warehouse to seed from. */
|
|
34
|
+
warehouseBaseUrl?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const cfg: AdminConfig =
|
|
38
|
+
(typeof window !== "undefined" && (window as any).__RASTACK_ADMIN__) || {
|
|
39
|
+
mode: "local",
|
|
40
|
+
schemaUrl: "/schema.rastack.json",
|
|
41
|
+
warehouseBaseUrl: "/data/warehouse/",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// The framework's own design system paints the whole console.
|
|
45
|
+
const theme = defineTheme({ brand: "#4F6BFF", radius: "rounded" });
|
|
46
|
+
|
|
47
|
+
/** Load the WASM engine from the URL `rastack dev` serves it at (`/wasm/…`). */
|
|
23
48
|
const loadEngine = async (): Promise<RastackWasmModule> => {
|
|
24
49
|
const runtimeImport = new Function("s", "return import(s)") as (
|
|
25
50
|
s: string,
|
|
@@ -30,55 +55,82 @@ const loadEngine = async (): Promise<RastackWasmModule> => {
|
|
|
30
55
|
return mod;
|
|
31
56
|
};
|
|
32
57
|
|
|
33
|
-
|
|
58
|
+
function Loading({ label }: { label: string }): React.JSX.Element {
|
|
59
|
+
return (
|
|
60
|
+
<div className="ras-boot">
|
|
61
|
+
<Spinner /> {label}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
34
65
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
66
|
+
/** Local (WASM) admin: wait for the provider's engine, then render. */
|
|
67
|
+
function LocalAdmin({ resources }: { resources: AdminResource[] }): React.JSX.Element {
|
|
68
|
+
const client = useRastackClient();
|
|
69
|
+
if (client.status === "error")
|
|
70
|
+
return <Loading label="The WASM engine failed to start — check the console." />;
|
|
71
|
+
if (!client.engine || client.status !== "ready")
|
|
72
|
+
return (
|
|
73
|
+
<Loading
|
|
74
|
+
label={
|
|
75
|
+
client.status === "seeding"
|
|
76
|
+
? "Seeding the local warehouse…"
|
|
77
|
+
: "Starting the WASM engine…"
|
|
78
|
+
}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
return <AdminApp engine={client.engine} resources={resources} />;
|
|
38
82
|
}
|
|
39
83
|
|
|
40
84
|
function Boot(): React.JSX.Element {
|
|
41
|
-
const [
|
|
42
|
-
const [
|
|
85
|
+
const [resources, setResources] = useState<AdminResource[] | null>(null);
|
|
86
|
+
const [manifest, setManifest] = useState<object | null>(null);
|
|
87
|
+
const [openapi, setOpenapi] = useState<object | undefined>();
|
|
88
|
+
const [error, setError] = useState<string>();
|
|
89
|
+
const httpEngine = useMemo(() => createHttpEngine(cfg.apiBase ?? ""), []);
|
|
43
90
|
|
|
44
91
|
useEffect(() => {
|
|
45
92
|
(async () => {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
93
|
+
const schemaUrl = cfg.schemaUrl || "/schema.rastack.json";
|
|
94
|
+
const res = await fetch(schemaUrl);
|
|
95
|
+
if (!res.ok) throw new Error(`${schemaUrl} (${res.status})`);
|
|
96
|
+
const doc = await res.json();
|
|
97
|
+
setManifest(doc);
|
|
98
|
+
setResources(parseManifest(doc));
|
|
99
|
+
if (cfg.mode === "local") {
|
|
100
|
+
try {
|
|
101
|
+
const o = await fetch("/openapi.json");
|
|
102
|
+
if (o.ok) setOpenapi(await o.json());
|
|
103
|
+
} catch {
|
|
104
|
+
/* openapi is optional */
|
|
105
|
+
}
|
|
56
106
|
}
|
|
57
|
-
setCompiled({ manifest, openapi });
|
|
58
107
|
})().catch((e) => setError(String(e)));
|
|
59
108
|
}, []);
|
|
60
109
|
|
|
61
110
|
if (error)
|
|
111
|
+
return <div className="ras-boot">Failed to load the schema: {error}</div>;
|
|
112
|
+
if (!resources || !manifest) return <Loading label="Loading the schema…" />;
|
|
113
|
+
|
|
114
|
+
if (cfg.mode === "remote") {
|
|
62
115
|
return (
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
</
|
|
116
|
+
<ThemeProvider tokens={theme}>
|
|
117
|
+
<AdminApp engine={httpEngine} resources={resources} />
|
|
118
|
+
</ThemeProvider>
|
|
66
119
|
);
|
|
67
|
-
|
|
68
|
-
return <div className="rastack-boot">Compiling the schema…</div>;
|
|
120
|
+
}
|
|
69
121
|
|
|
70
122
|
return (
|
|
71
123
|
<RAStackProvider
|
|
72
124
|
mode="local"
|
|
73
|
-
manifest={
|
|
74
|
-
openapi={
|
|
125
|
+
manifest={manifest}
|
|
126
|
+
openapi={openapi}
|
|
75
127
|
loadEngine={loadEngine}
|
|
76
|
-
warehouseBaseUrl="/data/warehouse/"
|
|
128
|
+
warehouseBaseUrl={cfg.warehouseBaseUrl ?? "/data/warehouse/"}
|
|
77
129
|
persist={false}
|
|
78
130
|
cache={false}
|
|
79
131
|
>
|
|
80
132
|
<ThemeProvider tokens={theme}>
|
|
81
|
-
<
|
|
133
|
+
<LocalAdmin resources={resources} />
|
|
82
134
|
</ThemeProvider>
|
|
83
135
|
</RAStackProvider>
|
|
84
136
|
);
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Linear-style changelist filter bar: active filters render as pill "chips"
|
|
3
|
+
* (`Field is Value ×`) whose value opens a switcher popover and whose × drops
|
|
4
|
+
* the filter; a dashed "+ Filter" button opens a two-level menu (pick a field →
|
|
5
|
+
* pick a value). Search sits at the right. Every value is served from the
|
|
6
|
+
* `FilterFieldModel`s discovered by `useFilterModel` (FK target rows, distinct
|
|
7
|
+
* strings, booleans), so the bar reflects the data, and each pick drives an
|
|
8
|
+
* equality filter through the engine.
|
|
9
|
+
*/
|
|
10
|
+
import React, { useState } from "react";
|
|
11
|
+
import type { FilterOption } from "../adapters";
|
|
12
|
+
import type { FilterFieldModel } from "../useAdmin";
|
|
13
|
+
import { Popover } from "./Popover";
|
|
14
|
+
import {
|
|
15
|
+
IconChevronRight,
|
|
16
|
+
IconClose,
|
|
17
|
+
IconFilter,
|
|
18
|
+
IconLink,
|
|
19
|
+
IconPlus,
|
|
20
|
+
IconSearch,
|
|
21
|
+
} from "./icons";
|
|
22
|
+
|
|
23
|
+
function facetIcon(kind: FilterFieldModel["kind"]): React.JSX.Element {
|
|
24
|
+
return kind === "fk" ? (
|
|
25
|
+
<IconLink size={15} className="ras-ico" />
|
|
26
|
+
) : (
|
|
27
|
+
<IconFilter size={15} className="ras-ico" />
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function labelFor(facet: FilterFieldModel | undefined, value: string): string {
|
|
32
|
+
return facet?.options.find((o) => o.value === value)?.label ?? value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A searchable list of value options (used by the add-menu and chip switcher). */
|
|
36
|
+
function OptionList(props: {
|
|
37
|
+
options: FilterOption[];
|
|
38
|
+
selected?: string;
|
|
39
|
+
onPick: (value: string) => void;
|
|
40
|
+
}): React.JSX.Element {
|
|
41
|
+
const [q, setQ] = useState("");
|
|
42
|
+
const shown = q
|
|
43
|
+
? props.options.filter((o) => o.label.toLowerCase().includes(q.toLowerCase()))
|
|
44
|
+
: props.options;
|
|
45
|
+
return (
|
|
46
|
+
<>
|
|
47
|
+
{props.options.length > 8 ? (
|
|
48
|
+
<input
|
|
49
|
+
className="ras-pop-search"
|
|
50
|
+
placeholder="Search…"
|
|
51
|
+
value={q}
|
|
52
|
+
onChange={(e) => setQ(e.target.value)}
|
|
53
|
+
autoFocus
|
|
54
|
+
/>
|
|
55
|
+
) : null}
|
|
56
|
+
<div className="ras-pop-list">
|
|
57
|
+
{shown.length === 0 ? (
|
|
58
|
+
<div className="ras-pop-empty">No matches</div>
|
|
59
|
+
) : (
|
|
60
|
+
shown.map((o) => (
|
|
61
|
+
<button
|
|
62
|
+
key={o.value}
|
|
63
|
+
type="button"
|
|
64
|
+
className={"ras-pop-item" + (o.value === props.selected ? " is-selected" : "")}
|
|
65
|
+
onClick={() => props.onPick(o.value)}
|
|
66
|
+
>
|
|
67
|
+
{o.label}
|
|
68
|
+
</button>
|
|
69
|
+
))
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The two-level "add filter" menu: fields → values. */
|
|
77
|
+
function AddFilterMenu(props: {
|
|
78
|
+
facets: FilterFieldModel[];
|
|
79
|
+
activeFields: string[];
|
|
80
|
+
onPick: (field: string, value: string) => void;
|
|
81
|
+
close: () => void;
|
|
82
|
+
}): React.JSX.Element {
|
|
83
|
+
const [view, setView] = useState<string | null>(null);
|
|
84
|
+
const available = props.facets.filter((f) => !props.activeFields.includes(f.name));
|
|
85
|
+
|
|
86
|
+
if (view) {
|
|
87
|
+
const facet = props.facets.find((f) => f.name === view);
|
|
88
|
+
if (!facet) return <div className="ras-pop-empty">—</div>;
|
|
89
|
+
return (
|
|
90
|
+
<>
|
|
91
|
+
<div className="ras-pop-head" onClick={() => setView(null)}>
|
|
92
|
+
<IconChevronRight size={14} style={{ transform: "rotate(180deg)" }} />
|
|
93
|
+
{facet.label}
|
|
94
|
+
</div>
|
|
95
|
+
<OptionList
|
|
96
|
+
options={facet.options}
|
|
97
|
+
onPick={(v) => {
|
|
98
|
+
props.onPick(facet.name, v);
|
|
99
|
+
props.close();
|
|
100
|
+
}}
|
|
101
|
+
/>
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className="ras-pop-list">
|
|
108
|
+
{available.length === 0 ? (
|
|
109
|
+
<div className="ras-pop-empty">No more filters</div>
|
|
110
|
+
) : (
|
|
111
|
+
available.map((f) => (
|
|
112
|
+
<button
|
|
113
|
+
key={f.name}
|
|
114
|
+
type="button"
|
|
115
|
+
className="ras-pop-item"
|
|
116
|
+
onClick={() => setView(f.name)}
|
|
117
|
+
>
|
|
118
|
+
{facetIcon(f.kind)}
|
|
119
|
+
{f.label}
|
|
120
|
+
<IconChevronRight size={14} className="ras-pop-item-chev" />
|
|
121
|
+
</button>
|
|
122
|
+
))
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function FilterBar(props: {
|
|
129
|
+
facets: FilterFieldModel[];
|
|
130
|
+
filters: Record<string, string>;
|
|
131
|
+
setFilter: (field: string, value: string) => void;
|
|
132
|
+
removeFilter: (field: string) => void;
|
|
133
|
+
clearFilters: () => void;
|
|
134
|
+
search: string | undefined;
|
|
135
|
+
setSearch: (value: string | undefined) => void;
|
|
136
|
+
searchable: boolean;
|
|
137
|
+
}): React.JSX.Element {
|
|
138
|
+
const active = Object.keys(props.filters);
|
|
139
|
+
const facetOf = (name: string) => props.facets.find((f) => f.name === name);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="ras-filterbar">
|
|
143
|
+
{active.map((field) => {
|
|
144
|
+
const facet = facetOf(field);
|
|
145
|
+
const value = props.filters[field];
|
|
146
|
+
return (
|
|
147
|
+
<span className="ras-chip" key={field}>
|
|
148
|
+
<span className="ras-chip-key">
|
|
149
|
+
{facet ? facetIcon(facet.kind) : null}
|
|
150
|
+
{facet?.label ?? field}
|
|
151
|
+
</span>
|
|
152
|
+
<span className="ras-chip-op">is</span>
|
|
153
|
+
{facet && facet.options.length ? (
|
|
154
|
+
<Popover
|
|
155
|
+
trigger={({ toggle }) => (
|
|
156
|
+
<span className="ras-chip-val" onClick={toggle} role="button" tabIndex={0}>
|
|
157
|
+
{labelFor(facet, value)}
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
160
|
+
>
|
|
161
|
+
{({ close }) => (
|
|
162
|
+
<OptionList
|
|
163
|
+
options={facet.options}
|
|
164
|
+
selected={value}
|
|
165
|
+
onPick={(v) => {
|
|
166
|
+
props.setFilter(field, v);
|
|
167
|
+
close();
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
</Popover>
|
|
172
|
+
) : (
|
|
173
|
+
<span className="ras-chip-val" style={{ cursor: "default" }}>
|
|
174
|
+
{labelFor(facet, value)}
|
|
175
|
+
</span>
|
|
176
|
+
)}
|
|
177
|
+
<span
|
|
178
|
+
className="ras-chip-x"
|
|
179
|
+
role="button"
|
|
180
|
+
tabIndex={0}
|
|
181
|
+
aria-label={`Remove ${facet?.label ?? field} filter`}
|
|
182
|
+
onClick={() => props.removeFilter(field)}
|
|
183
|
+
>
|
|
184
|
+
<IconClose size={13} />
|
|
185
|
+
</span>
|
|
186
|
+
</span>
|
|
187
|
+
);
|
|
188
|
+
})}
|
|
189
|
+
|
|
190
|
+
{props.facets.length ? (
|
|
191
|
+
<Popover
|
|
192
|
+
trigger={({ toggle }) => (
|
|
193
|
+
<button type="button" className="ras-addfilter" onClick={toggle}>
|
|
194
|
+
<IconPlus size={14} />
|
|
195
|
+
Filter
|
|
196
|
+
</button>
|
|
197
|
+
)}
|
|
198
|
+
>
|
|
199
|
+
{({ close }) => (
|
|
200
|
+
<AddFilterMenu
|
|
201
|
+
facets={props.facets}
|
|
202
|
+
activeFields={active}
|
|
203
|
+
onPick={props.setFilter}
|
|
204
|
+
close={close}
|
|
205
|
+
/>
|
|
206
|
+
)}
|
|
207
|
+
</Popover>
|
|
208
|
+
) : null}
|
|
209
|
+
|
|
210
|
+
{active.length > 1 ? (
|
|
211
|
+
<button type="button" className="ras-clearfilter" onClick={props.clearFilters}>
|
|
212
|
+
Clear all
|
|
213
|
+
</button>
|
|
214
|
+
) : null}
|
|
215
|
+
|
|
216
|
+
<span className="ras-grow" />
|
|
217
|
+
|
|
218
|
+
{props.searchable ? (
|
|
219
|
+
<span className="ras-search">
|
|
220
|
+
<IconSearch size={15} className="ras-search-ico" />
|
|
221
|
+
<input
|
|
222
|
+
type="search"
|
|
223
|
+
placeholder="Search…"
|
|
224
|
+
value={props.search ?? ""}
|
|
225
|
+
onChange={(e) => props.setSearch(e.target.value || undefined)}
|
|
226
|
+
/>
|
|
227
|
+
</span>
|
|
228
|
+
) : null}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny controlled popover: a trigger + a floating panel that closes on
|
|
3
|
+
* outside-click or Escape. Used by the Linear-style filter bar (the "add
|
|
4
|
+
* filter" menu and each chip's value switcher). Deliberately dependency-free —
|
|
5
|
+
* no portal, no positioning lib — the panel is absolutely positioned under its
|
|
6
|
+
* trigger and styled by `ui/styles.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
9
|
+
|
|
10
|
+
export interface PopoverRenderProps {
|
|
11
|
+
open: boolean;
|
|
12
|
+
toggle: () => void;
|
|
13
|
+
close: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Popover(props: {
|
|
17
|
+
align?: "left" | "right";
|
|
18
|
+
trigger: (p: PopoverRenderProps) => React.ReactNode;
|
|
19
|
+
children: (p: { close: () => void }) => React.ReactNode;
|
|
20
|
+
}): React.JSX.Element {
|
|
21
|
+
const [open, setOpen] = useState(false);
|
|
22
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
23
|
+
const close = () => setOpen(false);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!open) return;
|
|
27
|
+
const onDown = (e: MouseEvent) => {
|
|
28
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
29
|
+
};
|
|
30
|
+
const onKey = (e: KeyboardEvent) => {
|
|
31
|
+
if (e.key === "Escape") setOpen(false);
|
|
32
|
+
};
|
|
33
|
+
document.addEventListener("mousedown", onDown);
|
|
34
|
+
document.addEventListener("keydown", onKey);
|
|
35
|
+
return () => {
|
|
36
|
+
document.removeEventListener("mousedown", onDown);
|
|
37
|
+
document.removeEventListener("keydown", onKey);
|
|
38
|
+
};
|
|
39
|
+
}, [open]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="ras-pop-wrap" ref={ref}>
|
|
43
|
+
{props.trigger({ open, toggle: () => setOpen((o) => !o), close })}
|
|
44
|
+
{open ? (
|
|
45
|
+
<div
|
|
46
|
+
className={"ras-pop" + (props.align === "right" ? " align-right" : "")}
|
|
47
|
+
role="menu"
|
|
48
|
+
>
|
|
49
|
+
{props.children({ close })}
|
|
50
|
+
</div>
|
|
51
|
+
) : null}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|