@lightdash/cli 0.3427.0 → 0.3429.0
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.
|
@@ -1032,6 +1032,33 @@ const client = useLightdashClient();
|
|
|
1032
1032
|
const user = await client.auth.getUser();
|
|
1033
1033
|
```
|
|
1034
1034
|
|
|
1035
|
+
### Shareable URL state
|
|
1036
|
+
|
|
1037
|
+
The host page keeps a `?state=` query param in sync with the app's view state, so the
|
|
1038
|
+
browser's address bar is always a shareable link — a colleague who opens the link lands
|
|
1039
|
+
on the same view. **Use `useUrlState` instead of `useState` for every user-facing view
|
|
1040
|
+
control**: period/date-range selectors, active tab, segment pickers, sort toggles.
|
|
1041
|
+
|
|
1042
|
+
```tsx
|
|
1043
|
+
import { useUrlState } from '@lightdash/query-sdk';
|
|
1044
|
+
|
|
1045
|
+
const [period, setPeriod] = useUrlState('period', 'last_month');
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
- Drop-in `useState` shape (functional updates work). Values must be JSON-serializable.
|
|
1049
|
+
- Each control owns a stable string key; all keys share one map, so keep the total
|
|
1050
|
+
small (≤ 4 KB serialized — oversized state stays in memory but stops persisting).
|
|
1051
|
+
- **Global filters are persisted automatically** — `useGlobalFilters()` already stores
|
|
1052
|
+
its filters in URL state. Never wire filter state through `useUrlState` yourself.
|
|
1053
|
+
- **Treat the seeded value as untrusted.** It comes from a user-editable URL: validate
|
|
1054
|
+
it before use and fall back to the default when it's not what you expect
|
|
1055
|
+
(e.g. `PERIODS.includes(period) ? period : 'last_month'`).
|
|
1056
|
+
- Do NOT build "copy link" / "share" buttons — the host page owns the URL; sharing is
|
|
1057
|
+
just copying the address bar.
|
|
1058
|
+
- Ephemeral UI state (open dropdowns, hover, modal visibility, in-progress form input)
|
|
1059
|
+
stays in plain `useState` — only state that defines *which view* the user is looking
|
|
1060
|
+
at belongs in the URL.
|
|
1061
|
+
|
|
1035
1062
|
### External APIs
|
|
1036
1063
|
|
|
1037
1064
|
When an app needs data from an external HTTP API (Stripe, a CRM, a weather
|
|
@@ -1182,6 +1209,8 @@ Use `<Loader2 className="animate-spin" />` from `lucide-react`, not skeletons. G
|
|
|
1182
1209
|
|
|
1183
1210
|
> **Limitation by design:** A field that legitimately exists on multiple explores (e.g. `region` joined into both `orders` and `regional_sales`) won't cross-filter under this rule. That's the safe default. If the user explicitly asks for cross-explore linking on a shared dimension, you can call `addFilter` once per explore — but never broadcast a filter to all explores blindly.
|
|
1184
1211
|
|
|
1212
|
+
Global filters are automatically persisted to the host page URL (see [Shareable URL state](#shareable-url-state)) — filter selections survive reloads and shared links with no extra wiring.
|
|
1213
|
+
|
|
1185
1214
|
The filter context is pre-installed and wraps your app at the root. Import the hook from `@/lib/filters` — never reimplement it:
|
|
1186
1215
|
|
|
1187
1216
|
```tsx
|
|
@@ -3,13 +3,38 @@ import {
|
|
|
3
3
|
useCallback,
|
|
4
4
|
useContext,
|
|
5
5
|
useMemo,
|
|
6
|
-
useState,
|
|
7
6
|
type ReactNode,
|
|
8
7
|
} from 'react';
|
|
9
|
-
import type
|
|
8
|
+
import { useUrlState, type Filter } from '@lightdash/query-sdk';
|
|
10
9
|
|
|
11
10
|
export type ScopedFilter = Filter & { explore: string };
|
|
12
11
|
|
|
12
|
+
// Global filters live in URL state so the host page's address bar is always a
|
|
13
|
+
// shareable link to the current filtered view. The seeded value comes from a
|
|
14
|
+
// user-editable URL — sanitize before trusting its shape, or malformed
|
|
15
|
+
// entries flow into every metric query for the explore and fail the run.
|
|
16
|
+
const isScalar = (v: unknown): v is string | number | boolean =>
|
|
17
|
+
typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
|
|
18
|
+
|
|
19
|
+
const sanitizeFilters = (value: unknown): ScopedFilter[] =>
|
|
20
|
+
Array.isArray(value)
|
|
21
|
+
? value.filter((f): f is ScopedFilter => {
|
|
22
|
+
if (!f || typeof f !== 'object') return false;
|
|
23
|
+
const { explore, field, operator, value: filterValue } =
|
|
24
|
+
f as ScopedFilter;
|
|
25
|
+
return (
|
|
26
|
+
typeof explore === 'string' &&
|
|
27
|
+
typeof field === 'string' &&
|
|
28
|
+
typeof operator === 'string' &&
|
|
29
|
+
operator.length > 0 &&
|
|
30
|
+
(filterValue === undefined ||
|
|
31
|
+
isScalar(filterValue) ||
|
|
32
|
+
(Array.isArray(filterValue) &&
|
|
33
|
+
filterValue.every(isScalar)))
|
|
34
|
+
);
|
|
35
|
+
})
|
|
36
|
+
: [];
|
|
37
|
+
|
|
13
38
|
type FilterContextValue = {
|
|
14
39
|
addFilter: (filter: ScopedFilter) => void;
|
|
15
40
|
removeFilter: (filter: ScopedFilter) => void;
|
|
@@ -26,22 +51,35 @@ const sameTarget = (a: ScopedFilter, b: ScopedFilter) =>
|
|
|
26
51
|
JSON.stringify(a.value) === JSON.stringify(b.value);
|
|
27
52
|
|
|
28
53
|
export function FilterProvider({ children }: { children: ReactNode }) {
|
|
29
|
-
const [
|
|
54
|
+
const [rawFilters, setFilters] = useUrlState<ScopedFilter[]>(
|
|
55
|
+
'globalFilters',
|
|
56
|
+
[],
|
|
57
|
+
);
|
|
58
|
+
const filters = useMemo(() => sanitizeFilters(rawFilters), [rawFilters]);
|
|
30
59
|
|
|
31
|
-
const addFilter = useCallback(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
60
|
+
const addFilter = useCallback(
|
|
61
|
+
(filter: ScopedFilter) => {
|
|
62
|
+
setFilters((prev) => {
|
|
63
|
+
const current = sanitizeFilters(prev);
|
|
64
|
+
const exists = current.some((f) => sameTarget(f, filter));
|
|
65
|
+
return exists
|
|
66
|
+
? current.filter((f) => !sameTarget(f, filter))
|
|
67
|
+
: [...current, filter];
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
[setFilters],
|
|
71
|
+
);
|
|
39
72
|
|
|
40
|
-
const removeFilter = useCallback(
|
|
41
|
-
|
|
42
|
-
|
|
73
|
+
const removeFilter = useCallback(
|
|
74
|
+
(filter: ScopedFilter) => {
|
|
75
|
+
setFilters((prev) =>
|
|
76
|
+
sanitizeFilters(prev).filter((f) => !sameTarget(f, filter)),
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
[setFilters],
|
|
80
|
+
);
|
|
43
81
|
|
|
44
|
-
const clearFilters = useCallback(() => setFilters([]), []);
|
|
82
|
+
const clearFilters = useCallback(() => setFilters([]), [setFilters]);
|
|
45
83
|
|
|
46
84
|
const filtersFor = useCallback(
|
|
47
85
|
(explore: string): Filter[] =>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightdash/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3429.0",
|
|
4
4
|
"description": "Lightdash CLI tool",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"unique-names-generator": "4.7.1",
|
|
42
42
|
"uuid": "11.1.1",
|
|
43
43
|
"yaml": "2.8.3",
|
|
44
|
-
"@lightdash/
|
|
45
|
-
"@lightdash/
|
|
44
|
+
"@lightdash/common": "0.3429.0",
|
|
45
|
+
"@lightdash/warehouses": "0.3429.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/inquirer": "8.2.4",
|