@revenexx/studio-shared 0.3.1 → 0.3.3
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/dist/module.d.mts +4 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +9 -0
- package/dist/runtime/components/Dashboard/DashboardConfirmDialog.d.vue.ts +15 -0
- package/dist/runtime/components/Dashboard/DashboardConfirmDialog.vue +39 -0
- package/dist/runtime/components/Dashboard/DashboardConfirmDialog.vue.d.ts +15 -0
- package/dist/runtime/components/Dashboard/DashboardControls.d.vue.ts +57 -0
- package/dist/runtime/components/Dashboard/DashboardControls.vue +209 -0
- package/dist/runtime/components/Dashboard/DashboardControls.vue.d.ts +57 -0
- package/dist/runtime/components/Dashboard/DashboardGrid.d.vue.ts +16 -0
- package/dist/runtime/components/Dashboard/DashboardGrid.vue +87 -0
- package/dist/runtime/components/Dashboard/DashboardGrid.vue.d.ts +16 -0
- package/dist/runtime/components/Dashboard/DashboardNameDialog.d.vue.ts +18 -0
- package/dist/runtime/components/Dashboard/DashboardNameDialog.vue +61 -0
- package/dist/runtime/components/Dashboard/DashboardNameDialog.vue.d.ts +18 -0
- package/dist/runtime/components/Dashboard/DashboardPickerDialog.d.vue.ts +14 -0
- package/dist/runtime/components/Dashboard/DashboardPickerDialog.vue +100 -0
- package/dist/runtime/components/Dashboard/DashboardPickerDialog.vue.d.ts +14 -0
- package/dist/runtime/components/Dashboard/DashboardShareDialog.d.vue.ts +27 -0
- package/dist/runtime/components/Dashboard/DashboardShareDialog.vue +155 -0
- package/dist/runtime/components/Dashboard/DashboardShareDialog.vue.d.ts +27 -0
- package/dist/runtime/components/Dashboard/DashboardTopBar.d.vue.ts +59 -0
- package/dist/runtime/components/Dashboard/DashboardTopBar.vue +79 -0
- package/dist/runtime/components/Dashboard/DashboardTopBar.vue.d.ts +59 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetConfigDialog.d.vue.ts +42 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetConfigDialog.vue +153 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetConfigDialog.vue.d.ts +42 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetFrame.d.vue.ts +27 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetFrame.vue +80 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetFrame.vue.d.ts +27 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetHost.d.vue.ts +6 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetHost.vue +30 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetHost.vue.d.ts +6 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetIcon.d.vue.ts +8 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetIcon.vue +13 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetIcon.vue.d.ts +8 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetLibrary.d.vue.ts +23 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetLibrary.vue +140 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetLibrary.vue.d.ts +23 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetPreview.d.vue.ts +22 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetPreview.vue +124 -0
- package/dist/runtime/components/Dashboard/DashboardWidgetPreview.vue.d.ts +22 -0
- package/dist/runtime/components/DataTable/DataTable.d.vue.ts +39 -3
- package/dist/runtime/components/DataTable/DataTable.vue +306 -32
- package/dist/runtime/components/DataTable/DataTable.vue.d.ts +39 -3
- package/dist/runtime/components/DataTable/FacetChips.d.vue.ts +16 -0
- package/dist/runtime/components/DataTable/FacetChips.vue +72 -0
- package/dist/runtime/components/DataTable/FacetChips.vue.d.ts +16 -0
- package/dist/runtime/components/DataTable/FacetPanel.d.vue.ts +20 -0
- package/dist/runtime/components/DataTable/FacetPanel.vue +359 -0
- package/dist/runtime/components/DataTable/FacetPanel.vue.d.ts +20 -0
- package/dist/runtime/components/DataTable/SavedViewsMenu.d.vue.ts +5 -1
- package/dist/runtime/components/DataTable/SavedViewsMenu.vue +83 -63
- package/dist/runtime/components/DataTable/SavedViewsMenu.vue.d.ts +5 -1
- package/dist/runtime/components/DataTable/SortMenu.d.vue.ts +5 -1
- package/dist/runtime/components/DataTable/SortMenu.vue +40 -12
- package/dist/runtime/components/DataTable/SortMenu.vue.d.ts +5 -1
- package/dist/runtime/components/Media/MediaPickerDialog.d.vue.ts +2 -2
- package/dist/runtime/components/Media/MediaPickerDialog.vue.d.ts +2 -2
- package/dist/runtime/components/Media/MediaUploader.d.vue.ts +2 -2
- package/dist/runtime/components/Media/MediaUploader.vue.d.ts +2 -2
- package/dist/runtime/components/Shared/SortHeader.vue +6 -2
- package/dist/runtime/components/Shared/TabBar.d.vue.ts +23 -7
- package/dist/runtime/components/Shared/TabBar.vue +3 -2
- package/dist/runtime/components/Shared/TabBar.vue.d.ts +23 -7
- package/dist/runtime/composables/useDashboards.d.ts +27 -0
- package/dist/runtime/composables/useDashboards.js +143 -0
- package/dist/runtime/composables/useSavedViews.d.ts +44 -0
- package/dist/runtime/composables/useSavedViews.js +44 -0
- package/dist/runtime/composables/useStickyListView.d.ts +60 -0
- package/dist/runtime/composables/useStickyListView.js +32 -0
- package/dist/runtime/lib/canonicalJson.d.ts +43 -0
- package/dist/runtime/lib/canonicalJson.js +38 -0
- package/dist/runtime/lib/dashboardIcons.d.ts +4 -0
- package/dist/runtime/lib/dashboardIcons.js +50 -0
- package/dist/runtime/lib/facets.d.ts +200 -0
- package/dist/runtime/lib/facets.js +171 -0
- package/dist/runtime/lib/gridTracks.d.ts +55 -0
- package/dist/runtime/lib/gridTracks.js +29 -0
- package/dist/runtime/lib/widgetRegistry.d.ts +124 -0
- package/dist/runtime/lib/widgetRegistry.js +66 -0
- package/dist/runtime/types/dashboard.d.ts +95 -0
- package/dist/runtime/types/dashboard.js +2 -0
- package/dist/types.d.mts +8 -0
- package/package.json +4 -3
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
2
|
+
export class DashboardApiError extends Error {
|
|
3
|
+
constructor(status, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.name = "DashboardApiError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function useDashboards(scope, options = {}) {
|
|
10
|
+
const base = options.base ?? "/api/cockpit";
|
|
11
|
+
const auth = usePlatformAuth();
|
|
12
|
+
const tenant = usePlatformTenant();
|
|
13
|
+
const key = (name) => `studio.dashboards.${scope}.${name}`;
|
|
14
|
+
const dashboards = useState(key("list"), () => []);
|
|
15
|
+
const defaultId = useState(key("defaultId"), () => null);
|
|
16
|
+
const loaded = useState(key("loaded"), () => false);
|
|
17
|
+
const loading = useState(key("loading"), () => false);
|
|
18
|
+
const saving = useState(key("saving"), () => false);
|
|
19
|
+
const error = useState(key("error"), () => null);
|
|
20
|
+
const inFlight = ref(null);
|
|
21
|
+
async function headers() {
|
|
22
|
+
const token = await auth.getToken();
|
|
23
|
+
if (!token) throw new DashboardApiError(401, "Not signed in.");
|
|
24
|
+
if (!tenant.slug.value) await tenant.load();
|
|
25
|
+
const slug = tenant.slug.value;
|
|
26
|
+
if (!slug) throw new DashboardApiError(400, "No active tenant.");
|
|
27
|
+
return { Authorization: `Bearer ${token}`, "X-Revenexx-Tenant": slug };
|
|
28
|
+
}
|
|
29
|
+
function fail(err) {
|
|
30
|
+
const e = err;
|
|
31
|
+
const status = e.status ?? e.statusCode ?? e.response?.status ?? 0;
|
|
32
|
+
const message = e.data?.statusMessage ?? e.data?.message ?? e.statusMessage ?? e.message ?? "The dashboard service did not answer.";
|
|
33
|
+
throw new DashboardApiError(status, message);
|
|
34
|
+
}
|
|
35
|
+
async function call(path, init = {}) {
|
|
36
|
+
try {
|
|
37
|
+
return await $fetch(`${base}${path}`, {
|
|
38
|
+
method: init.method ?? "GET",
|
|
39
|
+
headers: await headers(),
|
|
40
|
+
query: init.query,
|
|
41
|
+
body: init.body
|
|
42
|
+
});
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err instanceof DashboardApiError) throw err;
|
|
45
|
+
fail(err);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function replace(updated) {
|
|
49
|
+
const idx = dashboards.value.findIndex((d) => d.id === updated.id);
|
|
50
|
+
dashboards.value = idx === -1 ? [...dashboards.value, updated] : dashboards.value.map((d) => d.id === updated.id ? updated : d);
|
|
51
|
+
}
|
|
52
|
+
async function run(force) {
|
|
53
|
+
loading.value = true;
|
|
54
|
+
error.value = null;
|
|
55
|
+
try {
|
|
56
|
+
const res = await call("/dashboards", { query: { scope } });
|
|
57
|
+
dashboards.value = res.dashboards;
|
|
58
|
+
defaultId.value = res.defaultDashboardId;
|
|
59
|
+
loaded.value = true;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
error.value = err.message;
|
|
62
|
+
if (force) throw err;
|
|
63
|
+
} finally {
|
|
64
|
+
loading.value = false;
|
|
65
|
+
}
|
|
66
|
+
return dashboards.value;
|
|
67
|
+
}
|
|
68
|
+
async function load(force = false) {
|
|
69
|
+
if (inFlight.value) return inFlight.value;
|
|
70
|
+
if (loaded.value && !force) return dashboards.value;
|
|
71
|
+
inFlight.value = run(force);
|
|
72
|
+
try {
|
|
73
|
+
return await inFlight.value;
|
|
74
|
+
} finally {
|
|
75
|
+
inFlight.value = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function create(payload) {
|
|
79
|
+
saving.value = true;
|
|
80
|
+
try {
|
|
81
|
+
const created = await call("/dashboards", { method: "POST", body: { ...payload, scope } });
|
|
82
|
+
replace(created);
|
|
83
|
+
return created;
|
|
84
|
+
} finally {
|
|
85
|
+
saving.value = false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function save(id, payload) {
|
|
89
|
+
const current = dashboards.value.find((d) => d.id === id);
|
|
90
|
+
saving.value = true;
|
|
91
|
+
try {
|
|
92
|
+
const updated = await call(`/dashboards/${id}`, {
|
|
93
|
+
method: "PATCH",
|
|
94
|
+
// The version travels with every write: a stale one is a 409 rather
|
|
95
|
+
// than a silent overwrite of the other tab's edit.
|
|
96
|
+
body: { ...payload, version: payload.version ?? current?.version }
|
|
97
|
+
});
|
|
98
|
+
replace(updated);
|
|
99
|
+
return updated;
|
|
100
|
+
} finally {
|
|
101
|
+
saving.value = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function remove(id) {
|
|
105
|
+
await call(`/dashboards/${id}`, { method: "DELETE" });
|
|
106
|
+
dashboards.value = dashboards.value.filter((d) => d.id !== id);
|
|
107
|
+
if (defaultId.value === id) defaultId.value = null;
|
|
108
|
+
}
|
|
109
|
+
async function duplicate(id) {
|
|
110
|
+
const copy = await call(`/dashboards/${id}/duplicate`, { method: "POST" });
|
|
111
|
+
replace(copy);
|
|
112
|
+
return copy;
|
|
113
|
+
}
|
|
114
|
+
async function setDefault(id) {
|
|
115
|
+
await call(`/dashboards/${id ?? "none"}/default`, { method: "PUT", body: { scope } });
|
|
116
|
+
defaultId.value = id;
|
|
117
|
+
dashboards.value = dashboards.value.map((d) => ({ ...d, isDefault: d.id === id }));
|
|
118
|
+
}
|
|
119
|
+
function resolve(slug) {
|
|
120
|
+
const list = dashboards.value;
|
|
121
|
+
if (!list.length) return null;
|
|
122
|
+
if (slug) {
|
|
123
|
+
const named = list.find((d) => d.slug === slug);
|
|
124
|
+
if (named) return named;
|
|
125
|
+
}
|
|
126
|
+
return list.find((d) => d.id === defaultId.value) ?? list[0] ?? null;
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
scope,
|
|
130
|
+
dashboards: computed(() => dashboards.value),
|
|
131
|
+
defaultId: computed(() => defaultId.value),
|
|
132
|
+
loading: computed(() => loading.value),
|
|
133
|
+
saving: computed(() => saving.value),
|
|
134
|
+
error: computed(() => error.value),
|
|
135
|
+
load,
|
|
136
|
+
resolve,
|
|
137
|
+
create,
|
|
138
|
+
save,
|
|
139
|
+
remove,
|
|
140
|
+
duplicate,
|
|
141
|
+
setDefault
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved-views storage provider — the DEFAULT (PE-608).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is a seam and not just an API call
|
|
5
|
+
*
|
|
6
|
+
* `SavedViewsMenu` ships inside `@revenexx/studio-shared`, which is published
|
|
7
|
+
* into hosts that have no cockpit backend: the standalone Commerce Studio dev
|
|
8
|
+
* host, and anything else that installs the module for its DataTable. So the
|
|
9
|
+
* component cannot reach for `/api/cockpit/saved-views` — it would 404 in every
|
|
10
|
+
* host but one, and the control would be permanently broken there.
|
|
11
|
+
*
|
|
12
|
+
* Same shape as `usePlatformAuth`, `usePlatformTenant` and `useMediaLibrary`:
|
|
13
|
+
* this file is the default, registered at `priority: -1`, and the cockpit ships
|
|
14
|
+
* a same-named app-level composable that wins. Here, views are kept in
|
|
15
|
+
* `localStorage`, which is exactly what the menu did before — so a bare host
|
|
16
|
+
* behaves as it always has, and only the cockpit gains the account-backed copy.
|
|
17
|
+
*
|
|
18
|
+
* ## Why `id` exists in a localStorage implementation
|
|
19
|
+
*
|
|
20
|
+
* The server keys a view by a uuid and the menu deletes by it. Making the local
|
|
21
|
+
* store agree means the component has one code path instead of two, and a
|
|
22
|
+
* `name`-keyed delete cannot be quietly wrong about which of two views with the
|
|
23
|
+
* same name it removed. The id is derived from the surface and the name, which
|
|
24
|
+
* is stable across reloads and is all the uniqueness a browser-local store has.
|
|
25
|
+
*/
|
|
26
|
+
/** One named snapshot of how a list was narrowed. */
|
|
27
|
+
export interface StoredSavedView {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
snapshot: unknown;
|
|
31
|
+
}
|
|
32
|
+
export interface SavedViewsStore {
|
|
33
|
+
/** Read this surface's views. Never throws — a failure is an empty list. */
|
|
34
|
+
list: (surface: string) => Promise<StoredSavedView[]>;
|
|
35
|
+
/** Save under a name, replacing what that name held. */
|
|
36
|
+
save: (surface: string, name: string, snapshot: unknown) => Promise<StoredSavedView>;
|
|
37
|
+
remove: (surface: string, id: string) => Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Does this store survive a new browser? The menu says so in one line, so
|
|
40
|
+
* "saved to your account" and "saved in this browser only" stay honest.
|
|
41
|
+
*/
|
|
42
|
+
readonly durable: boolean;
|
|
43
|
+
}
|
|
44
|
+
export declare function useSavedViews(): SavedViewsStore;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
function localId(surface, name) {
|
|
2
|
+
return `local:${surface}::${name}`;
|
|
3
|
+
}
|
|
4
|
+
function readAll(surface) {
|
|
5
|
+
try {
|
|
6
|
+
if (typeof window === "undefined") return [];
|
|
7
|
+
const raw = window.localStorage.getItem(surface);
|
|
8
|
+
if (!raw) return [];
|
|
9
|
+
const parsed = JSON.parse(raw);
|
|
10
|
+
if (!Array.isArray(parsed)) return [];
|
|
11
|
+
return parsed.filter((v) => !!v && typeof v === "object" && typeof v.name === "string").map((v) => ({ id: localId(surface, v.name), name: v.name, snapshot: v.snapshot }));
|
|
12
|
+
} catch {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function writeAll(surface, views) {
|
|
17
|
+
try {
|
|
18
|
+
if (typeof window === "undefined") return;
|
|
19
|
+
window.localStorage.setItem(surface, JSON.stringify(
|
|
20
|
+
views.map((v) => ({ name: v.name, snapshot: v.snapshot }))
|
|
21
|
+
));
|
|
22
|
+
} catch {
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function useSavedViews() {
|
|
26
|
+
return {
|
|
27
|
+
durable: false,
|
|
28
|
+
async list(surface) {
|
|
29
|
+
return readAll(surface);
|
|
30
|
+
},
|
|
31
|
+
async save(surface, name, snapshot) {
|
|
32
|
+
const views = readAll(surface);
|
|
33
|
+
const entry = { id: localId(surface, name), name, snapshot };
|
|
34
|
+
const at = views.findIndex((v) => v.name === name);
|
|
35
|
+
if (at >= 0) views.splice(at, 1, entry);
|
|
36
|
+
else views.push(entry);
|
|
37
|
+
writeAll(surface, views);
|
|
38
|
+
return entry;
|
|
39
|
+
},
|
|
40
|
+
async remove(surface, id) {
|
|
41
|
+
writeAll(surface, readAll(surface).filter((v) => v.id !== id));
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The narrowing a list was left in, kept across a page change (PE-608).
|
|
3
|
+
*
|
|
4
|
+
* Leaving a list to open a record — or to check another one — and coming back
|
|
5
|
+
* to an unfiltered table is the small tax every operator pays repeatedly: they
|
|
6
|
+
* re-tick the same facets, re-pick the same sort, and do it again the next
|
|
7
|
+
* time. The state is already a snapshot (it is what a saved view stores), so
|
|
8
|
+
* remembering the last one per surface costs nothing new.
|
|
9
|
+
*
|
|
10
|
+
* ## Why `sessionStorage` and not `localStorage`
|
|
11
|
+
*
|
|
12
|
+
* This is the decision worth arguing, because both are defensible and only one
|
|
13
|
+
* of them is safe by default.
|
|
14
|
+
*
|
|
15
|
+
* A sticky filter is a known way to lose people. An operator opens Orders, sees
|
|
16
|
+
* three rows out of twenty-three thousand, and concludes the data is gone —
|
|
17
|
+
* because the filter that did it was set last Tuesday and nothing on screen
|
|
18
|
+
* looks like an answer to "why is this list short". That is a support ticket,
|
|
19
|
+
* and a scary one.
|
|
20
|
+
*
|
|
21
|
+
* Two things make it safe here rather than merely convenient:
|
|
22
|
+
*
|
|
23
|
+
* 1. **It is visible.** The chip strip under the toolbar names every applied
|
|
24
|
+
* value and the trigger is lit. A restored narrowing announces itself; the
|
|
25
|
+
* version of this feature that is dangerous is the one you cannot see.
|
|
26
|
+
* 2. **It ends with the tab.** `sessionStorage` survives a navigation and a
|
|
27
|
+
* reload — which is what "come back to the list" means — and is gone when
|
|
28
|
+
* the tab closes. Tomorrow morning is a fresh list. `localStorage` would
|
|
29
|
+
* carry Friday's filter into Monday, and `localStorage` is also what the
|
|
30
|
+
* saved views deliberately stopped being: a durable narrowing is a thing
|
|
31
|
+
* you NAME and store on your account, not something a list does to you.
|
|
32
|
+
*
|
|
33
|
+
* So: sticky within a session, explicit across them. Swapping the two is one
|
|
34
|
+
* identifier if that trade ever reads differently.
|
|
35
|
+
*
|
|
36
|
+
* ## What it does not do
|
|
37
|
+
*
|
|
38
|
+
* It does not write the URL. A link to a list is a link to the list, not to how
|
|
39
|
+
* one person last left it — pasting a narrowed URL into a ticket and having a
|
|
40
|
+
* colleague see something else is worse than either behaviour here. The queue
|
|
41
|
+
* pills are the linkable half, and they already carry `?queue=` on purpose.
|
|
42
|
+
*/
|
|
43
|
+
export interface StickyListView<T> {
|
|
44
|
+
/**
|
|
45
|
+
* The snapshot this surface was last left in, or `null`.
|
|
46
|
+
*
|
|
47
|
+
* Read once, deliberately: a surface restores on mount and owns its state
|
|
48
|
+
* from then on. A reactive value here would fight the surface for it.
|
|
49
|
+
*/
|
|
50
|
+
read: () => T | null;
|
|
51
|
+
/** Remember the current snapshot. Call it whenever the surface's state moves. */
|
|
52
|
+
write: (snapshot: T) => void;
|
|
53
|
+
/** Forget this surface's state — what a "reset" should also do. */
|
|
54
|
+
clear: () => void;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* @param key A stable identifier for the surface — the same route pattern the
|
|
58
|
+
* saved views are keyed by, so two lists never share a memory.
|
|
59
|
+
*/
|
|
60
|
+
export declare function useStickyListView<T>(key: string): StickyListView<T>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const VERSION = 1;
|
|
2
|
+
export function useStickyListView(key) {
|
|
3
|
+
const storageKey = `commerce.listState.${key}`;
|
|
4
|
+
return {
|
|
5
|
+
read() {
|
|
6
|
+
try {
|
|
7
|
+
if (typeof window === "undefined") return null;
|
|
8
|
+
const raw = window.sessionStorage.getItem(storageKey);
|
|
9
|
+
if (!raw) return null;
|
|
10
|
+
const parsed = JSON.parse(raw);
|
|
11
|
+
if (!parsed || parsed.v !== VERSION) return null;
|
|
12
|
+
return parsed.snapshot ?? null;
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
write(snapshot) {
|
|
18
|
+
try {
|
|
19
|
+
if (typeof window === "undefined") return;
|
|
20
|
+
window.sessionStorage.setItem(storageKey, JSON.stringify({ v: VERSION, snapshot }));
|
|
21
|
+
} catch {
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
clear() {
|
|
25
|
+
try {
|
|
26
|
+
if (typeof window === "undefined") return;
|
|
27
|
+
window.sessionStorage.removeItem(storageKey);
|
|
28
|
+
} catch {
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stable string for any JSON value, for comparing two of them (PE-608).
|
|
3
|
+
*
|
|
4
|
+
* ## The bug this exists for
|
|
5
|
+
*
|
|
6
|
+
* `SavedViewsMenu` decides whether a stored view is the one currently applied
|
|
7
|
+
* by comparing snapshots, and it compared them with `JSON.stringify`. That
|
|
8
|
+
* works exactly as long as both sides were built by the same code in the same
|
|
9
|
+
* order — which stopped being true the moment saved views moved into Postgres.
|
|
10
|
+
*
|
|
11
|
+
* `jsonb` does not preserve key order; it normalises it. So a surface that
|
|
12
|
+
* builds `{ filters, sort }` gets `{ sort, filters }` back from the database,
|
|
13
|
+
* the two strings differ, and the applied view is never recognised as applied.
|
|
14
|
+
* Nothing throws and nothing looks broken — the menu simply never marks
|
|
15
|
+
* anything, and the trigger never shows that a view is in effect. It is the
|
|
16
|
+
* kind of defect that survives review because the code reads as obviously
|
|
17
|
+
* correct.
|
|
18
|
+
*
|
|
19
|
+
* ## What "canonical" means here
|
|
20
|
+
*
|
|
21
|
+
* Object keys sorted, recursively. Arrays left alone, because their order IS
|
|
22
|
+
* their meaning — `["a","b"]` and `["b","a"]` are different filter values as
|
|
23
|
+
* far as a query is concerned, and a facet that wants them treated as a set
|
|
24
|
+
* sorts them before it stores them.
|
|
25
|
+
*
|
|
26
|
+
* `undefined` is dropped from objects, exactly as `JSON.stringify` does, so an
|
|
27
|
+
* absent key and an explicitly-undefined one compare equal. That matters:
|
|
28
|
+
* `{ queue: undefined }` is what a spread produces for a surface with no queue,
|
|
29
|
+
* and the server round-trips it as an absent key.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Deterministic JSON for `value` — same input, same string, whatever order the
|
|
33
|
+
* keys arrived in.
|
|
34
|
+
*
|
|
35
|
+
* A cycle would make this recurse forever, so it is caught and reported as a
|
|
36
|
+
* sentinel rather than thrown: the one caller is a comparison in a computed,
|
|
37
|
+
* and a snapshot is not worth crashing a toolbar over. Two cyclic values
|
|
38
|
+
* compare equal, which is wrong but harmless — and a snapshot with a cycle
|
|
39
|
+
* could never have been stored in the first place.
|
|
40
|
+
*/
|
|
41
|
+
export declare function canonicalJson(value: unknown): string;
|
|
42
|
+
/** Do two JSON values mean the same thing, whatever order their keys are in? */
|
|
43
|
+
export declare function sameJson(a: unknown, b: unknown): boolean;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function canonicalJson(value) {
|
|
2
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
3
|
+
const walk = (input) => {
|
|
4
|
+
if (input === null) return "null";
|
|
5
|
+
const type = typeof input;
|
|
6
|
+
if (type === "number") return Number.isFinite(input) ? String(input) : "null";
|
|
7
|
+
if (type === "boolean") return String(input);
|
|
8
|
+
if (type === "string") return JSON.stringify(input);
|
|
9
|
+
if (type === "undefined" || type === "function" || type === "symbol") return "undefined";
|
|
10
|
+
if (type === "bigint") return JSON.stringify(String(input));
|
|
11
|
+
const object = input;
|
|
12
|
+
if (seen.has(object)) return '"[cycle]"';
|
|
13
|
+
seen.add(object);
|
|
14
|
+
try {
|
|
15
|
+
if (Array.isArray(object)) {
|
|
16
|
+
return `[${object.map((item) => {
|
|
17
|
+
const part = walk(item);
|
|
18
|
+
return part === "undefined" ? "null" : part;
|
|
19
|
+
}).join(",")}]`;
|
|
20
|
+
}
|
|
21
|
+
if (object instanceof Date) return JSON.stringify(object.toISOString());
|
|
22
|
+
const parts = [];
|
|
23
|
+
for (const key of Object.keys(object).sort()) {
|
|
24
|
+
const part = walk(object[key]);
|
|
25
|
+
if (part === "undefined") continue;
|
|
26
|
+
parts.push(`${JSON.stringify(key)}:${part}`);
|
|
27
|
+
}
|
|
28
|
+
return `{${parts.join(",")}}`;
|
|
29
|
+
} finally {
|
|
30
|
+
seen.delete(object);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const out = walk(value);
|
|
34
|
+
return out === "undefined" ? "null" : out;
|
|
35
|
+
}
|
|
36
|
+
export function sameJson(a, b) {
|
|
37
|
+
return canonicalJson(a) === canonicalJson(b);
|
|
38
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Bag,
|
|
3
|
+
BillList,
|
|
4
|
+
Bolt,
|
|
5
|
+
Box,
|
|
6
|
+
Card,
|
|
7
|
+
Cart,
|
|
8
|
+
ChartSquare,
|
|
9
|
+
ClipboardList,
|
|
10
|
+
DangerTriangle,
|
|
11
|
+
Earth,
|
|
12
|
+
GraphUp,
|
|
13
|
+
Hashtag,
|
|
14
|
+
Home,
|
|
15
|
+
Layers,
|
|
16
|
+
List,
|
|
17
|
+
MagicStick,
|
|
18
|
+
NotebookMinimalistic,
|
|
19
|
+
Pulse,
|
|
20
|
+
Routing,
|
|
21
|
+
UsersGroupRounded,
|
|
22
|
+
Widget
|
|
23
|
+
} from "#solar-icons";
|
|
24
|
+
export const DASHBOARD_ICONS = {
|
|
25
|
+
Bag,
|
|
26
|
+
BillList,
|
|
27
|
+
Bolt,
|
|
28
|
+
Box,
|
|
29
|
+
Card,
|
|
30
|
+
Cart,
|
|
31
|
+
ChartSquare,
|
|
32
|
+
ClipboardList,
|
|
33
|
+
DangerTriangle,
|
|
34
|
+
Earth,
|
|
35
|
+
GraphUp,
|
|
36
|
+
Hashtag,
|
|
37
|
+
Home,
|
|
38
|
+
Layers,
|
|
39
|
+
List,
|
|
40
|
+
MagicStick,
|
|
41
|
+
NotebookMinimalistic,
|
|
42
|
+
Pulse,
|
|
43
|
+
Routing,
|
|
44
|
+
UsersGroupRounded,
|
|
45
|
+
Widget
|
|
46
|
+
};
|
|
47
|
+
export const PICKABLE_ICONS = Object.keys(DASHBOARD_ICONS);
|
|
48
|
+
export function dashboardIcon(name) {
|
|
49
|
+
return name && DASHBOARD_ICONS[name] || Widget;
|
|
50
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One facet model for every way a list gets narrowed (PE-6xx).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* The Commerce Studio grew seven mechanisms for the same job. Five of them
|
|
7
|
+
* describe the identical thing in different words:
|
|
8
|
+
*
|
|
9
|
+
* - `filters[type=select]` — a column, a vocabulary, options
|
|
10
|
+
* - `filters[type=multi-select]`— the same, several values
|
|
11
|
+
* - `filters[type=boolean]` — the same, two values
|
|
12
|
+
* - `filters[type=text]` — the same column, matched by substring
|
|
13
|
+
* - `filters[type=date-range]` — the same column, bounded
|
|
14
|
+
* - `rail.sections[type=facet]` — a column, a vocabulary, options. Again.
|
|
15
|
+
* - `rail.sections[type=tree]` — a column reached through a junction
|
|
16
|
+
*
|
|
17
|
+
* Each was rendered by its own control, in its own place, with its own commit
|
|
18
|
+
* behaviour, and none of them could hold more than one value except the one
|
|
19
|
+
* type nobody declared. This module is the single shape all of them reduce to,
|
|
20
|
+
* so a surface declares a dimension once and the CONTROL is a rendering
|
|
21
|
+
* decision rather than a second grammar.
|
|
22
|
+
*
|
|
23
|
+
* ## A facet is a question, not a widget
|
|
24
|
+
*
|
|
25
|
+
* `kind` says what shape the answer takes — a set of options, a typed string, a
|
|
26
|
+
* bounded range, a node in a hierarchy. It does NOT say where the control
|
|
27
|
+
* lives, because there is only one: the filter panel. An earlier pass gave a
|
|
28
|
+
* facet a `place` so a catalogue tree could stay pinned in a left rail, and
|
|
29
|
+
* that hedge put the studio back where it started — two places to look, two
|
|
30
|
+
* commit rules, and no way for an app author to know which one a dimension
|
|
31
|
+
* belonged in.
|
|
32
|
+
*
|
|
33
|
+
* ## Every facet is multi-valued
|
|
34
|
+
*
|
|
35
|
+
* `check` and `tree` hold a LIST. That is the point of the rework: "active or
|
|
36
|
+
* draft" was not a question the product could ask, because a `select` mapped to
|
|
37
|
+
* `eq.` and a submenu could only tick one row. Within a facet the values are
|
|
38
|
+
* OR'd, across facets AND'd — "a draft or a lead, in Germany".
|
|
39
|
+
*
|
|
40
|
+
* `text` and `date-range` hold one value each because there is only one to
|
|
41
|
+
* hold; they are still facets, and they still live in the panel rather than
|
|
42
|
+
* growing a second control in the toolbar.
|
|
43
|
+
*/
|
|
44
|
+
export type FacetKind = 'check' | 'text' | 'date-range' | 'tree';
|
|
45
|
+
export interface FacetOption {
|
|
46
|
+
value: string;
|
|
47
|
+
label: string;
|
|
48
|
+
/**
|
|
49
|
+
* How many rows match. Shown where known — "Churned 4" is the difference
|
|
50
|
+
* between a guess and a decision. Left `undefined` where the surface could
|
|
51
|
+
* not ask cheaply; a facet with no counts renders bare rather than lying
|
|
52
|
+
* with a zero.
|
|
53
|
+
*/
|
|
54
|
+
count?: number;
|
|
55
|
+
/** Greyed and unclickable, kept visible so the dimension still reads whole. */
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** One node of a `tree` facet. */
|
|
59
|
+
export interface FacetNode {
|
|
60
|
+
value: string;
|
|
61
|
+
label: string;
|
|
62
|
+
count?: number;
|
|
63
|
+
children?: FacetNode[];
|
|
64
|
+
}
|
|
65
|
+
export interface Facet {
|
|
66
|
+
/** Stable id. Namespaces this facet's values in the flat selection. */
|
|
67
|
+
key: string;
|
|
68
|
+
label: string;
|
|
69
|
+
kind: FacetKind;
|
|
70
|
+
/** `check` — the values on offer. */
|
|
71
|
+
options?: FacetOption[];
|
|
72
|
+
/** `tree` — the hierarchy on offer. */
|
|
73
|
+
nodes?: FacetNode[];
|
|
74
|
+
/** Placeholder for a `text` facet's input. */
|
|
75
|
+
placeholder?: string;
|
|
76
|
+
/** One line under the pane saying what the facet actually matches. */
|
|
77
|
+
hint?: string;
|
|
78
|
+
/**
|
|
79
|
+
* The pane is still loading its options. Renders a skeleton instead of an
|
|
80
|
+
* empty list, which otherwise reads as "this dimension has no values".
|
|
81
|
+
*/
|
|
82
|
+
loading?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* One value at a time — picking a second replaces the first.
|
|
85
|
+
*
|
|
86
|
+
* For a dimension whose values are alternatives rather than a set. A list's
|
|
87
|
+
* work queues are the case this exists for: "waiting for approval" AND
|
|
88
|
+
* "blocked" at once is not a wider question, it is a query that matches
|
|
89
|
+
* nothing, so offering it as a pair of checkboxes offers a dead end.
|
|
90
|
+
*/
|
|
91
|
+
single?: boolean;
|
|
92
|
+
}
|
|
93
|
+
/** `{ from, to }`, either side optional. */
|
|
94
|
+
export interface FacetRange {
|
|
95
|
+
from?: string;
|
|
96
|
+
to?: string;
|
|
97
|
+
}
|
|
98
|
+
export type FacetValue = string[] | string | FacetRange;
|
|
99
|
+
/**
|
|
100
|
+
* What is selected, keyed by facet. A map rather than the flat
|
|
101
|
+
* `"status:active"` array the design system's own panel takes, because `text`
|
|
102
|
+
* and `date-range` values do not survive being squashed into a string that is
|
|
103
|
+
* split on its first colon — a typed `"12:30"` or an ISO instant would break
|
|
104
|
+
* apart. {@link facetValueIds} produces the flat form where a component wants
|
|
105
|
+
* it (chips, aria labels).
|
|
106
|
+
*/
|
|
107
|
+
export type FacetSelection = Record<string, FacetValue>;
|
|
108
|
+
/**
|
|
109
|
+
* Is this value "no answer"? The one place that decides, because three
|
|
110
|
+
* different guesses at it is how a cleared filter keeps narrowing a query.
|
|
111
|
+
*
|
|
112
|
+
* `false` is a real answer, not emptiness — a boolean facet whose only tick is
|
|
113
|
+
* "No" must survive. Which is why this checks the SHAPE and never truthiness.
|
|
114
|
+
*/
|
|
115
|
+
export declare function isEmptyFacetValue(value: FacetValue | null | undefined): boolean;
|
|
116
|
+
/** The selection with every empty entry dropped. What a query should read. */
|
|
117
|
+
export declare function pruneSelection(selection: FacetSelection): FacetSelection;
|
|
118
|
+
/**
|
|
119
|
+
* How many values are set inside one facet — the badge on its row in the panel.
|
|
120
|
+
*
|
|
121
|
+
* A `text` or `date-range` facet counts as one when answered: the badge says
|
|
122
|
+
* "this dimension is narrowing", and "1" is the honest number for one typed
|
|
123
|
+
* string. Counting its characters, or a range's two bounds as two, would make
|
|
124
|
+
* the panel's total disagree with the number of chips beside it.
|
|
125
|
+
*/
|
|
126
|
+
export declare function countInFacet(selection: FacetSelection, key: string): number;
|
|
127
|
+
/** Everything set, across every facet. The count on the toolbar trigger. */
|
|
128
|
+
export declare function countSelected(facets: Facet[], selection: FacetSelection): number;
|
|
129
|
+
/** Depth-first walk, descending only into expanded nodes. */
|
|
130
|
+
export declare function flattenNodes(nodes: FacetNode[], expanded: ReadonlySet<string>): Array<{
|
|
131
|
+
node: FacetNode;
|
|
132
|
+
depth: number;
|
|
133
|
+
}>;
|
|
134
|
+
/** Every node, flat and depthless. What a search filters over. */
|
|
135
|
+
export declare function allNodes(nodes: FacetNode[]): FacetNode[];
|
|
136
|
+
/**
|
|
137
|
+
* The path from a root down to `value`, inclusive. What a chip reads
|
|
138
|
+
* ("Tools › Hand tools"): a deep node's own label is frequently ambiguous on
|
|
139
|
+
* its own — three branches of a catalogue each have an "Accessories".
|
|
140
|
+
*/
|
|
141
|
+
export declare function nodePath(nodes: FacetNode[], value: string): FacetNode[] | null;
|
|
142
|
+
/**
|
|
143
|
+
* The ids on the path ABOVE `value`, so a node selected from a saved view or a
|
|
144
|
+
* restored URL is revealed rather than left selected and invisible.
|
|
145
|
+
*/
|
|
146
|
+
export declare function expandTo(nodes: FacetNode[], value: string | null | undefined): Set<string>;
|
|
147
|
+
/** The set a fresh tree opens with: the roots, and nothing deeper. */
|
|
148
|
+
export declare function initialExpanded(nodes: FacetNode[]): Set<string>;
|
|
149
|
+
export interface AppliedFacet {
|
|
150
|
+
/** Flat id, unique across the selection — the chip's `key`. */
|
|
151
|
+
id: string;
|
|
152
|
+
facetKey: string;
|
|
153
|
+
facetLabel: string;
|
|
154
|
+
/** What the chip says. A tree node carries its path. */
|
|
155
|
+
label: string;
|
|
156
|
+
/** The single value this chip removes. `undefined` clears the whole facet. */
|
|
157
|
+
value?: string;
|
|
158
|
+
}
|
|
159
|
+
/** The flat ids of one facet's values, for a chip `key` or an aria label. */
|
|
160
|
+
export declare function facetValueIds(key: string, value: FacetValue): string[];
|
|
161
|
+
/**
|
|
162
|
+
* What is filtering right now, in the order the facets are DECLARED rather
|
|
163
|
+
* than the order they were clicked — a chip strip that reshuffles itself as you
|
|
164
|
+
* work is one people stop reading.
|
|
165
|
+
*
|
|
166
|
+
* `rangeLabel` and `textLabel` are passed in rather than built here: a range
|
|
167
|
+
* reads through the operator's own date format and a typed string wants
|
|
168
|
+
* quotation marks the UI language agrees with, and neither belongs in a pure
|
|
169
|
+
* derivation lib.
|
|
170
|
+
*/
|
|
171
|
+
export declare function appliedFacets(facets: Facet[], selection: FacetSelection, format: {
|
|
172
|
+
range: (range: FacetRange) => string;
|
|
173
|
+
text: (value: string) => string;
|
|
174
|
+
}): AppliedFacet[];
|
|
175
|
+
/**
|
|
176
|
+
* Tick or untick one value of a multi-valued facet, returning a NEW selection.
|
|
177
|
+
*
|
|
178
|
+
* A facet whose last value is removed is deleted from the map rather than left
|
|
179
|
+
* as an empty array: `pruneSelection` would drop it anyway, and a lingering
|
|
180
|
+
* `{ status: [] }` makes two selections that mean the same thing compare
|
|
181
|
+
* unequal — which is what a dirty-check and a saved view both rely on.
|
|
182
|
+
*/
|
|
183
|
+
export declare function toggleFacetValue(selection: FacetSelection, key: string, value: string, on: boolean, opts?: {
|
|
184
|
+
single?: boolean;
|
|
185
|
+
}): FacetSelection;
|
|
186
|
+
/** Set (or clear) a `text` / `date-range` facet, returning a NEW selection. */
|
|
187
|
+
export declare function setFacetValue(selection: FacetSelection, key: string, value: FacetValue): FacetSelection;
|
|
188
|
+
/** Remove one chip. Clears the whole facet for the single-valued kinds. */
|
|
189
|
+
export declare function clearFacetValue(selection: FacetSelection, key: string, value?: string): FacetSelection;
|
|
190
|
+
/**
|
|
191
|
+
* Do two selections mean the same thing? Drives the Apply button's dot and the
|
|
192
|
+
* decision to re-query at all.
|
|
193
|
+
*
|
|
194
|
+
* Order-insensitive within a facet: ticking A then B and ticking B then A are
|
|
195
|
+
* the same filter, and a dirty flag that disagreed would offer an Apply that
|
|
196
|
+
* changes nothing.
|
|
197
|
+
*/
|
|
198
|
+
export declare function sameSelection(a: FacetSelection, b: FacetSelection): boolean;
|
|
199
|
+
/** Deep-enough copy for a draft. The values are strings, arrays and flat pairs. */
|
|
200
|
+
export declare function copySelection(selection: FacetSelection): FacetSelection;
|