@ciromaciel/dashboard-kit 1.0.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.
Files changed (38) hide show
  1. package/README.md +40 -0
  2. package/package.json +46 -0
  3. package/src/auth-labels.js +77 -0
  4. package/src/components/ActiveResource.jsx +68 -0
  5. package/src/components/AuthLoader.jsx +66 -0
  6. package/src/components/AuthScreen.jsx +32 -0
  7. package/src/components/CardSkeleton.jsx +82 -0
  8. package/src/components/CommandCenter.css +57 -0
  9. package/src/components/CommandCenter.jsx +185 -0
  10. package/src/components/DashboardFrame.jsx +97 -0
  11. package/src/components/DataTable.jsx +153 -0
  12. package/src/components/DeleteModal.jsx +341 -0
  13. package/src/components/EmptyState.jsx +55 -0
  14. package/src/components/GroupLabel.jsx +28 -0
  15. package/src/components/HoldToConfirm.jsx +151 -0
  16. package/src/components/ListSkeleton.jsx +55 -0
  17. package/src/components/MenuItem.jsx +56 -0
  18. package/src/components/Metric.jsx +178 -0
  19. package/src/components/MoreResults.jsx +137 -0
  20. package/src/components/NavItem.jsx +57 -0
  21. package/src/components/PageHeader.jsx +124 -0
  22. package/src/components/PlanBanner.jsx +88 -0
  23. package/src/components/ResourceItem.jsx +93 -0
  24. package/src/components/SectionCard.jsx +212 -0
  25. package/src/components/SidebarShell.jsx +271 -0
  26. package/src/components/SupportModal.jsx +269 -0
  27. package/src/components/TableSkeleton.jsx +43 -0
  28. package/src/components/TableToolbar.jsx +136 -0
  29. package/src/components/Terminal.jsx +217 -0
  30. package/src/components/Wordmark.jsx +52 -0
  31. package/src/components/ZenLabel.jsx +30 -0
  32. package/src/components/notify-theme.js +103 -0
  33. package/src/components/notify.js +65 -0
  34. package/src/components/table-grid.js +27 -0
  35. package/src/hooks/useExclusiveModal.js +49 -0
  36. package/src/hooks/usePaginatedList.js +206 -0
  37. package/src/index.js +99 -0
  38. package/src/tokens.css +47 -0
@@ -0,0 +1,206 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ /**
4
+ * A server-backed list, one page at a time.
5
+ *
6
+ * THIS HOOK IS THE GENERALIZATION OF `useMonitorsPaginated`, and it exists
7
+ * because the four decisions below cannot be deduced by reading the code — they
8
+ * were found by measuring. Every screen that paginated on its own would have to
9
+ * rediscover all four, and the first three fail silently.
10
+ *
11
+ * 1. SEARCH, FILTER AND ORDER GO TO THE SERVER. Without that, paginating BREAKS
12
+ * search: a client-side search over the loaded page says "no results" for an
13
+ * item that exists on page 3 — worse than having no search at all, because it
14
+ * looks like an answer. Changing any of the three returns to the first page;
15
+ * keeping the offset would show an empty list with "23 results" at the top.
16
+ *
17
+ * 2. THE RELOAD BRINGS BACK WHAT IS ALREADY ON SCREEN, not just the first page.
18
+ * Someone who scrolled to the fourth page and watches the periodic cycle
19
+ * return 50 rows has lost both their place and their work. `offset: 0` with
20
+ * the accumulated size is a larger request in bytes, and the only one that
21
+ * preserves position.
22
+ *
23
+ * 3. `loadingMore` IS SEPARATE FROM `loading`. The first keeps the table on
24
+ * screen with a loading footer; the second swaps everything for a skeleton.
25
+ * A single flag would make the whole list flash on every new page.
26
+ *
27
+ * 4. THE IN-FLIGHT RESPONSE IS INVALIDATED BY GENERATION, not by
28
+ * AbortController. Typing fast fires N searches, and the slow response to the
29
+ * first can arrive after the fifth — the classic race. A counter that only
30
+ * accepts the response of the current generation solves it without requiring
31
+ * the server to understand cancellation.
32
+ *
33
+ * THE SYNCHRONOUS LOCK (`fetchingMore`) deserves its own note: `loadingMore` is
34
+ * React state and only takes effect after render, but the `IntersectionObserver`
35
+ * in `MoreResults` fires several times within the same frame while the scroll
36
+ * runs. Measured: every page was requested TWICE — seven requests for four
37
+ * pages. A `ref` changes at the very instant of the call, so the second attempt
38
+ * in the same frame already finds the lock closed.
39
+ *
40
+ * @param {function} fetchPage - `({ limit, offset }) => Promise<{items, page}>`.
41
+ * The contract envelope; see `collection()` in
42
+ * `@ciromaciel/contract`.
43
+ * @param {array} deps - What, when changed, returns to the first page:
44
+ * search, filter, order. Same as a `useEffect`
45
+ * dependency array.
46
+ * @param {number} size - Items per page.
47
+ * @param {number} intervalMs - Periodic reload. `0` disables it.
48
+ * @param {function} pace - `(items) => ms`, to speed up the reload while
49
+ * work is in progress. Monitors returns 4s while
50
+ * some row is auditing and 30s at rest: an audit
51
+ * lasts about a minute, and without this the row
52
+ * spends half a minute showing the stale state
53
+ * AFTER it has finished.
54
+ */
55
+ export function usePaginatedList({ fetchPage, deps = [], size = 50, intervalMs = 0, pace } = {}) {
56
+ const [items, setItems] = useState([])
57
+ const [loading, setLoading] = useState(true)
58
+ const [loadingMore, setLoadingMore] = useState(false)
59
+ const [error, setError] = useState(null)
60
+ const [total, setTotal] = useState(0)
61
+ const [hasMore, setHasMore] = useState(false)
62
+ /*
63
+ * THE REST OF THE BODY, outside `items` and `page`.
64
+ *
65
+ * The contract puts at the ROOT whatever is context about the set rather
66
+ * than pagination: Monitors' per-state `counts`, Hoster's `activeDeployId`,
67
+ * Storage's blob `cursor`. Without exposing that, every panel needing one of
68
+ * them would have to keep its own copy of this hook — which is exactly the
69
+ * situation it came to undo.
70
+ */
71
+ const [extra, setExtra] = useState(null)
72
+
73
+ const alive = useRef(true)
74
+ const generation = useRef(0)
75
+ const loaded = useRef(0)
76
+ const fetchingMore = useRef(false)
77
+
78
+ // `fetchPage` is usually a fresh arrow on every render. Held in a ref, it
79
+ // stays out of the dependencies — otherwise the effect would restart on
80
+ // every render and the list would fetch the first page forever.
81
+ const fetchPageRef = useRef(fetchPage)
82
+ fetchPageRef.current = fetchPage
83
+
84
+ const requestPage = useCallback(
85
+ async ({ offset, limit = size, mode }) => {
86
+ const mine = ++generation.current
87
+
88
+ if (mode === 'more') setLoadingMore(true)
89
+ else if (mode === 'replace') setLoading(true)
90
+
91
+ try {
92
+ const r = await fetchPageRef.current({ limit, offset })
93
+ if (!alive.current || mine !== generation.current) return
94
+
95
+ const list = r?.items ?? []
96
+ const append = offset > 0 && mode === 'more'
97
+
98
+ setItems(previous => (append ? [...previous, ...list] : list))
99
+ loaded.current = append ? loaded.current + list.length : list.length
100
+
101
+ setTotal(r?.page?.total ?? list.length)
102
+ setHasMore(Boolean(r?.page?.hasMore))
103
+
104
+ // Anything that is neither `items` nor `page` is context about the set.
105
+ const { items: _i, page: _p, ...rest } = r ?? {}
106
+ setExtra(Object.keys(rest).length ? rest : null)
107
+
108
+ setError(null)
109
+ } catch (e) {
110
+ if (alive.current && mine === generation.current) setError(e?.message ?? String(e))
111
+ } finally {
112
+ // The lock ALWAYS opens, including when the response is discarded by
113
+ // generation — stuck, infinite scroll simply stops working.
114
+ if (mode === 'more') fetchingMore.current = false
115
+ if (alive.current && mine === generation.current) {
116
+ setLoading(false)
117
+ setLoadingMore(false)
118
+ }
119
+ }
120
+ },
121
+ [size],
122
+ )
123
+
124
+ const loadMore = useCallback(() => {
125
+ if (fetchingMore.current || loading || !hasMore) return
126
+ fetchingMore.current = true
127
+ requestPage({ offset: loaded.current, mode: 'more' })
128
+ }, [requestPage, loading, hasMore])
129
+
130
+ /** Reloads what is on screen, preserving the scroll position (decision 2). */
131
+ const reload = useCallback(() => requestPage({ offset: 0, limit: Math.max(loaded.current, size), mode: 'silent' }), [requestPage, size])
132
+
133
+ /*
134
+ * The trigger goes through a `setTimeout(0)` rather than straight into the
135
+ * effect body. `requestPage` calls `setLoading(true)` synchronously, and a
136
+ * setState in the effect body chains an extra render on every keystroke in
137
+ * the search box. Scheduled, the loading state lands after the commit — and
138
+ * the `clearTimeout` cancels the search for a term the user has already
139
+ * replaced, before it even leaves.
140
+ */
141
+ useEffect(() => {
142
+ alive.current = true
143
+ loaded.current = 0
144
+ fetchingMore.current = false
145
+
146
+ /*
147
+ * THE GENERATION IS INCREMENTED HERE, and not only inside `requestPage`.
148
+ *
149
+ * The `setTimeout(0)` below defers the request until after the commit.
150
+ * Without this increment, changing the search during an in-flight
151
+ * response played out like this: the cleanup cancelled the new
152
+ * generation's timer BEFORE it existed, the slow response from the old
153
+ * generation arrived, found the counter unchanged — and won. The list
154
+ * ended up showing the result for the term the user had already
155
+ * replaced.
156
+ *
157
+ * Invalidating at the moment of the CHANGE, not at the moment of the
158
+ * request, is what closes that window: any earlier response is already
159
+ * born stale.
160
+ */
161
+ generation.current++
162
+
163
+ const t = setTimeout(() => requestPage({ offset: 0, mode: 'replace' }), 0)
164
+ return () => {
165
+ clearTimeout(t)
166
+ alive.current = false
167
+ }
168
+ // eslint-disable-next-line react-hooks/exhaustive-deps
169
+ }, [requestPage, ...deps])
170
+
171
+ /*
172
+ * THE PACE FOLLOWS WHAT IS HAPPENING.
173
+ *
174
+ * A fixed interval is right at rest, but wrong while work is in progress:
175
+ * during a long-running operation the row can spend half a minute showing
176
+ * the stale state AFTER it has finished. `pace` lets the product decide —
177
+ * and it returns to the resting interval on its own.
178
+ */
179
+ const cadence = pace?.(items) || intervalMs
180
+
181
+ useEffect(() => {
182
+ if (!cadence) return
183
+ const t = setInterval(reload, cadence)
184
+ return () => clearInterval(t)
185
+ }, [reload, cadence])
186
+
187
+ return { items, loading, loadingMore, error, total, hasMore, extra, loadMore, reload }
188
+ }
189
+
190
+ /**
191
+ * Waits for the user to stop typing before going to the network.
192
+ *
193
+ * A client-side search does not need this — it is not worth delaying the
194
+ * response to the keyboard in order to filter an in-memory array. A SERVER
195
+ * search does: every keystroke would be a request, and 300 ms is the interval at
196
+ * which the person typing perceives no wait but the server receives one search
197
+ * instead of ten.
198
+ */
199
+ export function useDebounce(value, ms = 300) {
200
+ const [delayed, setDelayed] = useState(value)
201
+ useEffect(() => {
202
+ const t = setTimeout(() => setDelayed(value), ms)
203
+ return () => clearTimeout(t)
204
+ }, [value, ms])
205
+ return delayed
206
+ }
package/src/index.js ADDED
@@ -0,0 +1,99 @@
1
+ // ============================================================================
2
+ // @ciromaciel/dashboard-kit — what the seven dashboards share
3
+ // ============================================================================
4
+ //
5
+ // The websites already had `@ciromaciel/components-kit`. The DASHBOARDS had
6
+ // nothing: each one kept its own copy of the table, the loading state, the
7
+ // empty state and the destructive confirmation — 4,624 duplicated lines across
8
+ // four of them, plus seven copies of `AuthLoader`.
9
+ //
10
+ // THE ADMISSION CRITERION
11
+ //
12
+ // What goes in here is what is THE SAME across the dashboards. What belongs to
13
+ // a product stays out: the Monitors status light, its boot skeleton, the
14
+ // Payments currency rule. The dividing line is simple — if a dashboard had to
15
+ // change the component to serve its product, the component isn't everyone's
16
+ // yet.
17
+ //
18
+ // WHAT THE EXTRACTION FOUND
19
+ //
20
+ // Four components were byte-for-byte identical across four dashboards. Three
21
+ // had two variants, and in each case the divergence was EVOLUTION, not drift:
22
+ // the Hoster added `quietSelection` to the table with the reasoning written
23
+ // down, Monitors fixed a `<div>` inside a `<p>` that the browser was tearing
24
+ // apart. The kit kept the superset in each case — no dashboard loses behavior
25
+ // by adopting it.
26
+ //
27
+ // `Wordmark` was the one genuine exception: the four copies diverged because
28
+ // the product name was a literal in the JSX, and changing one word forced you
29
+ // to maintain the whole file. Here it comes in as a prop.
30
+ //
31
+ // WHY THERE IS NO BUILD
32
+ //
33
+ // The package publishes source. All seven dashboards are Vite, which compiles
34
+ // JSX from a dependency without ceremony — and a build step would only exist to
35
+ // produce what the consumer already knows how to produce. One less moving part
36
+ // to break.
37
+
38
+ import './tokens.css'
39
+
40
+ export { DataTable } from './components/DataTable'
41
+ export { templateFor, ROW_PADDING, HEAD_PADDING } from './components/table-grid'
42
+ export { TableSkeleton } from './components/TableSkeleton'
43
+ export { EmptyState } from './components/EmptyState'
44
+ export { SectionCard, Field, FieldStacked, CardActions } from './components/SectionCard'
45
+ export { PageHeader } from './components/PageHeader'
46
+
47
+ // ── The billing banner ───────────────────────────────────────────────────
48
+ // Freeze, do not disconnect: it warns, it never blocks. It takes the state
49
+ // from `usePlanNotice(product)` in `@ciromaciel/payments-react` and paints it —
50
+ // the kit does not know the Payments' address.
51
+ export { PlanBanner } from './components/PlanBanner'
52
+ export { HoldToConfirmIcon, HoldToConfirmButton } from './components/HoldToConfirm'
53
+
54
+ // ── The destructive modal ────────────────────────────────────────────────
55
+ // The top of the escalation: reversible → hold → this. An audit found fifteen
56
+ // of them across the seven panels and no two alike, so the shape lives here
57
+ // now. `design-system/10-modal-de-exclusao.md` says why.
58
+ export { DeleteModal, deleteTitleScale } from './components/DeleteModal'
59
+ export { ZenLabel } from './components/ZenLabel'
60
+ export { notify } from './components/notify'
61
+ export { NOTIFICATIONS_PROPS, NOTIFY_DESCRIPTION_CSS, notificationTheme } from './components/notify-theme'
62
+ export { Wordmark } from './components/Wordmark'
63
+ export { SupportModal, HealthRow, useHealthCheck } from './components/SupportModal'
64
+ export { AuthLoader } from './components/AuthLoader'
65
+ export { AuthScreen } from './components/AuthScreen'
66
+ export { Terminal, TerminalOutput } from './components/Terminal'
67
+ export { useExclusiveModal } from './hooks/useExclusiveModal'
68
+
69
+ // ── The sidebar's pieces ─────────────────────────────────────────────────
70
+ // Extracted after measuring: `MenuItem` was byte-identical in seven panels
71
+ // (the seventh differing only by quote style), and `NavItem`/`GroupLabel`
72
+ // differed by one optional prop each.
73
+ export { MenuItem } from './components/MenuItem'
74
+ export { NavItem } from './components/NavItem'
75
+ export { GroupLabel } from './components/GroupLabel'
76
+ export { ResourceItem } from './components/ResourceItem'
77
+ export { ActiveResource } from './components/ActiveResource'
78
+ export { ListSkeleton } from './components/ListSkeleton'
79
+ export { DashboardFrame, SIDEBAR_WIDTH } from './components/DashboardFrame'
80
+ export { SidebarShell } from './components/SidebarShell'
81
+ export { AUTH_LABELS, AUTH_TEXTS } from './auth-labels'
82
+ export { CardSkeleton } from './components/CardSkeleton'
83
+ export { MoreResults } from './components/MoreResults'
84
+ export { TableToolbar } from './components/TableToolbar'
85
+
86
+ // ── The paginated list ───────────────────────────────────────────────────
87
+ // The hook and the footer travel together: `usePaginatedList` holds the four
88
+ // decisions you can't infer from reading the code, and `MoreResults` is the
89
+ // only surface that triggers them. See the hook's header.
90
+ export { usePaginatedList, useDebounce } from './hooks/usePaginatedList'
91
+
92
+ // ── The screen ───────────────────────────────────────────────────────────
93
+ // `Metric` and `CommandCenter` aren't just components: they are the pattern for
94
+ // the first screen. Each dashboard used to call its own something different —
95
+ // "Visão geral", "Painel", "Agendamentos" — and none of those names says what
96
+ // the screen does. See the header of `CommandCenter.jsx` for the three laws it
97
+ // carries.
98
+ export { Metric } from './components/Metric'
99
+ export { CommandCenter } from './components/CommandCenter'
package/src/tokens.css ADDED
@@ -0,0 +1,47 @@
1
+ /*
2
+ * THE TOKENS THE KIT USES AND CANNOT ASSUME EXIST.
3
+ *
4
+ * Six components in this package draw a hairline with `var(--rlg-border)` — the
5
+ * table, its header, the skeleton, the search, the pagination footer and the
6
+ * Command Center's metrics band. The variable was born in each dashboard's CSS,
7
+ * and six of the seven defined it. The seventh (the Auth) didn't — and there the
8
+ * migrated table would have rendered WITH NO HAIRLINE AT ALL: header, rows and
9
+ * search flush against each other, with no build error and no console warning,
10
+ * because a `var()` with no value and no fallback simply doesn't apply the
11
+ * property.
12
+ *
13
+ * A package that requires a token to draw correctly has to ship that token's
14
+ * default value. `@property` doesn't work here (the value is a border
15
+ * shorthand), and repeating the fallback across the 13 call sites would be
16
+ * thirteen places to diverge.
17
+ *
18
+ * This is a FLOOR, not a lock: any dashboard's `:root` beats this declaration by
19
+ * cascade order — the product's CSS is imported afterwards.
20
+ */
21
+ :root {
22
+ --rlg-border: 1px solid var(--mantine-color-gray-2);
23
+
24
+ /*
25
+ * O filete dentro do Terminal, sobre fundo escuro. Nasceu literal em cinco
26
+ * cópias e virou token no Hoster; o piso fica aqui para que o componente
27
+ * não dependa de cada painel declarar a variável.
28
+ */
29
+ --rlg-terminal-rule: rgba(255, 255, 255, 0.08);
30
+
31
+ /*
32
+ * The system's only green, used by the SupportModal's status light.
33
+ *
34
+ * Five panels declared it with this same value; Auth and Storage did not —
35
+ * and those are exactly the two that did not yet have the modal. Without
36
+ * this floor the "No ar" dot would render WITH NO COLOUR at all in those
37
+ * two: a `var()` with no value and no fallback does not apply the property,
38
+ * and there is no build error and no console warning. It is the same defect
39
+ * as the hairline described above.
40
+ *
41
+ * It lives outside the gray ramp because this is not the success of an
42
+ * ACTION (that is gray.9 + IconCheck): it is the measured state of an
43
+ * external system. The rule that keeps the exception honest is that the
44
+ * colour never appears without the word beside it.
45
+ */
46
+ --rlg-status-up: #16a34a;
47
+ }