@lightdash/cli 0.3428.0 → 0.3429.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.
@@ -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 { Filter } from '@lightdash/query-sdk';
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 [filters, setFilters] = useState<ScopedFilter[]>([]);
54
+ const [rawFilters, setFilters] = useUrlState<ScopedFilter[]>(
55
+ 'globalFilters',
56
+ [],
57
+ );
58
+ const filters = useMemo(() => sanitizeFilters(rawFilters), [rawFilters]);
30
59
 
31
- const addFilter = useCallback((filter: ScopedFilter) => {
32
- setFilters((prev) => {
33
- const exists = prev.some((f) => sameTarget(f, filter));
34
- return exists
35
- ? prev.filter((f) => !sameTarget(f, filter))
36
- : [...prev, filter];
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((filter: ScopedFilter) => {
41
- setFilters((prev) => prev.filter((f) => !sameTarget(f, filter)));
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.3428.0",
3
+ "version": "0.3429.1",
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/warehouses": "0.3428.0",
45
- "@lightdash/common": "0.3428.0"
44
+ "@lightdash/common": "0.3429.1",
45
+ "@lightdash/warehouses": "0.3429.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/inquirer": "8.2.4",