@sukramj/fabric-ui 0.1.0 → 0.1.1

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": "@sukramj/fabric-ui",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The web interface of go-fabric nodes: Svelte 5 pages, UI kit, design tokens with a Home Assistant theme bridge, and the generated API client.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SukramJ",
@@ -8,6 +8,7 @@ import type { components } from "./schema.gen";
8
8
  type S = components["schemas"];
9
9
  export type Problem = S["Problem"];
10
10
  export type Status = S["Status"];
11
+ export type Warning = S["Warning"];
11
12
  export type Fabric = S["Fabric"];
12
13
  export type FabricList = S["FabricList"];
13
14
  export type Pairing = S["Pairing"];
@@ -501,6 +501,19 @@ export interface components {
501
501
  exposure: boolean;
502
502
  /** @description The admin token of token mode; absent when the host decides admin rights. */
503
503
  csrf_token?: string;
504
+ /** @description The host's findings about its own configuration; absent when the host reports none (no Options.Warnings), empty when it finds nothing. */
505
+ warnings?: components["schemas"]["Warning"][];
506
+ };
507
+ Warning: {
508
+ /** @description The kind of finding, stable across host releases. */
509
+ code: string;
510
+ /** @description What is wrong, in the host's language. */
511
+ message: string;
512
+ /**
513
+ * Format: date-time
514
+ * @description When the host first saw it; absent when unknown.
515
+ */
516
+ since?: string;
504
517
  };
505
518
  Fabric: {
506
519
  fabric_index: number;
@@ -653,6 +666,8 @@ export interface components {
653
666
  label: string;
654
667
  /** @description Offer a value filter built from the listed values. */
655
668
  filter: boolean;
669
+ /** @description Default false. The field is a comma-separated list: shown as chips, filtered by membership, the filter's options built from the individual values. */
670
+ multi?: boolean;
656
671
  };
657
672
  FilterOption: {
658
673
  value: string;
@@ -662,6 +677,8 @@ export interface components {
662
677
  field: string;
663
678
  label: string;
664
679
  options: components["schemas"]["FilterOption"][];
680
+ /** @description Default false. Match by membership in the field's comma-separated list (values trimmed; an empty value never matches) instead of equality. */
681
+ multi?: boolean;
665
682
  };
666
683
  ExposureSchema: {
667
684
  columns: components["schemas"]["Column"][];
@@ -152,6 +152,9 @@
152
152
  "node.tab.selection": "Auswahl",
153
153
  "node.tab.status": "Status",
154
154
  "node.title": "Knoten",
155
+ "node.warnings.since": "seit {time}",
156
+ "node.warnings.summary": "{count} Konfigurationswarnungen",
157
+ "node.warnings.title": "Konfigurationswarnungen",
155
158
  "select.placeholder": "Auswählen…",
156
159
  "shell.language": "Sprache"
157
160
  }
@@ -152,6 +152,9 @@
152
152
  "node.tab.selection": "Selection",
153
153
  "node.tab.status": "Status",
154
154
  "node.title": "Node",
155
+ "node.warnings.since": "since {time}",
156
+ "node.warnings.summary": "{count} configuration warnings",
157
+ "node.warnings.title": "Configuration warnings",
155
158
  "select.placeholder": "Select…",
156
159
  "shell.language": "Language"
157
160
  }
@@ -0,0 +1,41 @@
1
+ <!-- The host's findings about its own configuration (Status.warnings):
2
+ a card on the status page, a collapsed list above the selection. -->
3
+ <script lang="ts">
4
+ import { useFabricUI } from "../../context";
5
+ import type { Warning } from "../../api/client";
6
+ import Badge from "../../kit/Badge.svelte";
7
+ import Card from "../../kit/Card.svelte";
8
+
9
+ type Props = { warnings: Warning[]; collapsed?: boolean };
10
+ let { warnings, collapsed = false }: Props = $props();
11
+
12
+ const { t } = useFabricUI();
13
+ </script>
14
+
15
+ {#snippet list()}
16
+ <ul class="space-y-2">
17
+ {#each warnings as w, i (i)}
18
+ <li class="flex flex-wrap items-center gap-2 text-sm">
19
+ <Badge variant="warning">{w.code}</Badge>
20
+ <span class="text-[var(--ha-primary-text-color)]">{w.message}</span>
21
+ {#if w.since}
22
+ <span class="text-xs text-[var(--ha-secondary-text-color)]">{t("node.warnings.since", { time: new Date(w.since).toLocaleString() })}</span>
23
+ {/if}
24
+ </li>
25
+ {/each}
26
+ </ul>
27
+ {/snippet}
28
+
29
+ {#if warnings.length > 0}
30
+ {#if collapsed}
31
+ <details class="mb-3 rounded-md border border-[var(--ha-divider-color)] px-3 py-2" data-testid="host-warnings">
32
+ <summary class="cursor-pointer text-sm font-medium text-[var(--ha-warning-color)]">{t("node.warnings.summary", { count: warnings.length })}</summary>
33
+ <div class="mt-2">{@render list()}</div>
34
+ </details>
35
+ {:else}
36
+ <Card class="mb-4 p-4" data-testid="host-warnings">
37
+ <h3 class="mb-2 font-medium text-[var(--ha-primary-text-color)]">{t("node.warnings.title")}</h3>
38
+ {@render list()}
39
+ </Card>
40
+ {/if}
41
+ {/if}
@@ -5,7 +5,9 @@
5
5
  assets/ui/src/routes/matter/MatterExposureList.svelte:1-584 at
6
6
  b40b289a: the host's fixed columns, kind filter and per-item conflict
7
7
  hints become the schema's columns and filters; the class chips become a
8
- value filter per filterable column; Preview before Apply is new. -->
8
+ value filter per filterable column; Preview before Apply is new, and so
9
+ are list-valued fields (Column.multi, Filter.multi) and the host's
10
+ warnings above the list. -->
9
11
  <script lang="ts">
10
12
  import { onMount } from "svelte";
11
13
  import { useFabricUI } from "../../context";
@@ -24,6 +26,8 @@
24
26
  import Input from "../../kit/Input.svelte";
25
27
  import LoadingState from "../../kit/LoadingState.svelte";
26
28
  import Select from "../../kit/Select.svelte";
29
+ import HostWarnings from "./HostWarnings.svelte";
30
+ import { distinctValues, fieldMatches, listValues } from "./fields";
27
31
 
28
32
  const { t, toast, node } = useFabricUI();
29
33
 
@@ -32,6 +36,9 @@
32
36
  const unavailable = $derived(node.status !== null && !node.status.exposure);
33
37
 
34
38
  onMount(() => {
39
+ // The status carries the host's warnings; a host page that mounts the
40
+ // selection alone has not loaded it yet.
41
+ if (node.status === null && !node.statusLoading) void node.loadStatus();
35
42
  if (!unavailable) void node.loadExposure();
36
43
  });
37
44
 
@@ -49,19 +56,21 @@
49
56
  const schema = $derived(node.schema);
50
57
 
51
58
  // The value filters: the host's fixed filters, then one per filterable
52
- // column with the distinct values the items carry.
53
- type ValueFilter = { field: string; label: string; options: { value: string; label: string }[] };
59
+ // column with the distinct values the items carry — for a list-valued
60
+ // column the individual values, not their combinations.
61
+ type ValueFilter = { field: string; label: string; multi: boolean; options: { value: string; label: string }[] };
54
62
  const valueFilters = $derived.by<ValueFilter[]>(() => {
55
63
  if (!schema) return [];
56
- const out: ValueFilter[] = schema.filters.map((f) => ({ field: f.field, label: f.label, options: f.options }));
64
+ const out: ValueFilter[] = schema.filters.map((f) => ({ field: f.field, label: f.label, multi: !!f.multi, options: f.options }));
57
65
  for (const col of schema.columns) {
58
66
  if (!col.filter || out.some((f) => f.field === col.key)) continue;
59
- const values = [...new Set(node.items.map((i) => i.fields[col.key]).filter((v): v is string => !!v))];
60
- values.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }));
61
- out.push({ field: col.key, label: col.label, options: values.map((v) => ({ value: v, label: v })) });
67
+ const values = distinctValues(node.items.map((i) => i.fields[col.key]), !!col.multi);
68
+ out.push({ field: col.key, label: col.label, multi: !!col.multi, options: values.map((v) => ({ value: v, label: v })) });
62
69
  }
63
70
  return out;
64
71
  });
72
+ const multiField = $derived(new Set(valueFilters.filter((f) => f.multi).map((f) => f.field)));
73
+ const multiColumn = $derived(new Set((schema?.columns ?? []).filter((c) => c.multi).map((c) => "field:" + c.key)));
65
74
 
66
75
  // The item as the page shows it: with its pending change applied.
67
76
  function current(item: Item): Item {
@@ -74,7 +83,7 @@
74
83
  const match = makeTextMatcher(search);
75
84
  return node.items.filter((item) => {
76
85
  for (const [field, value] of Object.entries(filterValues)) {
77
- if (value !== ALL && item.fields[field] !== value) return false;
86
+ if (value !== ALL && !fieldMatches(item.fields[field], value, multiField.has(field))) return false;
78
87
  }
79
88
  if (!search.trim()) return true;
80
89
  const it = current(item);
@@ -218,6 +227,15 @@
218
227
  </button>
219
228
  {:else if col.key === "device_type"}
220
229
  <span class="text-[var(--ha-secondary-text-color)]">{item.verdict.device_type_name || "—"}</span>
230
+ {:else if multiColumn.has(col.key)}
231
+ {@const values = listValues(item.fields[col.key.slice("field:".length)])}
232
+ {#if values.length === 0}
233
+ <span class="text-[var(--ha-secondary-text-color)]">—</span>
234
+ {:else}
235
+ <span class="inline-flex flex-wrap gap-1">
236
+ {#each values as v, i (i)}<Badge variant="muted">{v}</Badge>{/each}
237
+ </span>
238
+ {/if}
221
239
  {:else}
222
240
  <span class="text-[var(--ha-secondary-text-color)]">{col.get?.(item) || "—"}</span>
223
241
  {/if}
@@ -230,6 +248,7 @@
230
248
  {:else if node.exposureError}
231
249
  <ErrorState message={node.exposureError} onRetry={() => void node.loadExposure()} />
232
250
  {:else if schema}
251
+ <HostWarnings warnings={node.status?.warnings ?? []} collapsed />
233
252
  <div class="mb-3 flex flex-col gap-2">
234
253
  <div class="flex flex-wrap items-center gap-2">
235
254
  <Input placeholder={t("node.selection.search_placeholder")} bind:value={search} class="w-full sm:w-64" />
@@ -1,7 +1,8 @@
1
1
  <!-- The node's status: whether it runs, listens, is healthy and
2
2
  advertised, its counts and the commissioning window. Ported from the
3
3
  status card of openccu-loom assets/ui/src/routes/matter/Matter.svelte:47-100
4
- at b40b289a, without the tabs (the shell or the host navigates). -->
4
+ at b40b289a, without the tabs (the shell or the host navigates). The
5
+ host's configuration warnings come first, in their own card. -->
5
6
  <script lang="ts">
6
7
  import { onMount } from "svelte";
7
8
  import { useFabricUI } from "../../context";
@@ -9,6 +10,7 @@
9
10
  import Card from "../../kit/Card.svelte";
10
11
  import ErrorState from "../../kit/ErrorState.svelte";
11
12
  import LoadingState from "../../kit/LoadingState.svelte";
13
+ import HostWarnings from "./HostWarnings.svelte";
12
14
 
13
15
  const { t, node } = useFabricUI();
14
16
 
@@ -24,10 +26,12 @@
24
26
  {:else if node.statusError && !s}
25
27
  <ErrorState message={node.statusError} onRetry={() => void node.loadStatus()} />
26
28
  {:else if s && !s.enabled}
29
+ <HostWarnings warnings={s.warnings ?? []} />
27
30
  <Card class="p-4">
28
31
  <p class="text-sm font-medium text-[var(--ha-secondary-text-color)]">{t("node.status.disabled")}</p>
29
32
  </Card>
30
33
  {:else if s}
34
+ <HostWarnings warnings={s.warnings ?? []} />
31
35
  <Card class="p-4" data-testid="node-status">
32
36
  <div class="flex flex-wrap items-center gap-4">
33
37
  <span class="font-medium text-[var(--ha-primary-text-color)]">{t("node.status.enabled")}</span>
@@ -0,0 +1,32 @@
1
+ // List-valued item fields (Column.multi, Filter.multi): the provider
2
+ // carries several values as one comma-separated string.
3
+
4
+ /** The values of a comma-separated field, trimmed, without empty ones. */
5
+ export function listValues(field: string | undefined): string[] {
6
+ if (!field) return [];
7
+ return field
8
+ .split(",")
9
+ .map((v) => v.trim())
10
+ .filter((v) => v !== "");
11
+ }
12
+
13
+ /**
14
+ * Whether an item's field matches a filter value: by equality, or, for a
15
+ * list-valued field, by membership in its list. An empty value never
16
+ * matches a list.
17
+ */
18
+ export function fieldMatches(field: string | undefined, value: string, multi: boolean): boolean {
19
+ if (!multi) return field === value;
20
+ const v = value.trim();
21
+ return v !== "" && listValues(field).includes(v);
22
+ }
23
+
24
+ /** The distinct filter values of a field across items, sorted for display. */
25
+ export function distinctValues(fields: (string | undefined)[], multi: boolean): string[] {
26
+ const values = new Set<string>();
27
+ for (const f of fields) {
28
+ if (multi) for (const v of listValues(f)) values.add(v);
29
+ else if (f) values.add(f);
30
+ }
31
+ return [...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }));
32
+ }