@modern-admin/react 0.1.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.
- package/dist/action-guard.d.ts +13 -0
- package/dist/action-guard.d.ts.map +1 -0
- package/dist/action-guard.js +15 -0
- package/dist/action-guard.js.map +1 -0
- package/dist/action-menu.d.ts +17 -0
- package/dist/action-menu.d.ts.map +1 -0
- package/dist/action-menu.jsx +80 -0
- package/dist/action-menu.jsx.map +1 -0
- package/dist/admin-app.d.ts +23 -0
- package/dist/admin-app.d.ts.map +1 -0
- package/dist/admin-app.jsx +407 -0
- package/dist/admin-app.jsx.map +1 -0
- package/dist/admin-router.d.ts +29 -0
- package/dist/admin-router.d.ts.map +1 -0
- package/dist/admin-router.jsx +215 -0
- package/dist/admin-router.jsx.map +1 -0
- package/dist/breadcrumbs.d.ts +17 -0
- package/dist/breadcrumbs.d.ts.map +1 -0
- package/dist/breadcrumbs.jsx +40 -0
- package/dist/breadcrumbs.jsx.map +1 -0
- package/dist/client.d.ts +526 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +582 -0
- package/dist/client.js.map +1 -0
- package/dist/component-loader.d.ts +10 -0
- package/dist/component-loader.d.ts.map +1 -0
- package/dist/component-loader.js +23 -0
- package/dist/component-loader.js.map +1 -0
- package/dist/components/ai-assistant-widget.d.ts +3 -0
- package/dist/components/ai-assistant-widget.d.ts.map +1 -0
- package/dist/components/ai-assistant-widget.jsx +390 -0
- package/dist/components/ai-assistant-widget.jsx.map +1 -0
- package/dist/components/ai-fill-dialog.d.ts +9 -0
- package/dist/components/ai-fill-dialog.d.ts.map +1 -0
- package/dist/components/ai-fill-dialog.jsx +105 -0
- package/dist/components/ai-fill-dialog.jsx.map +1 -0
- package/dist/components/chart-builder-dialog.d.ts +10 -0
- package/dist/components/chart-builder-dialog.d.ts.map +1 -0
- package/dist/components/chart-builder-dialog.jsx +433 -0
- package/dist/components/chart-builder-dialog.jsx.map +1 -0
- package/dist/components/chart-widget.d.ts +12 -0
- package/dist/components/chart-widget.d.ts.map +1 -0
- package/dist/components/chart-widget.jsx +365 -0
- package/dist/components/chart-widget.jsx.map +1 -0
- package/dist/components/global-search-dialog.d.ts +7 -0
- package/dist/components/global-search-dialog.d.ts.map +1 -0
- package/dist/components/global-search-dialog.jsx +187 -0
- package/dist/components/global-search-dialog.jsx.map +1 -0
- package/dist/components/group-settings-dialog.d.ts +13 -0
- package/dist/components/group-settings-dialog.d.ts.map +1 -0
- package/dist/components/group-settings-dialog.jsx +53 -0
- package/dist/components/group-settings-dialog.jsx.map +1 -0
- package/dist/components/move-chart-dialog.d.ts +18 -0
- package/dist/components/move-chart-dialog.d.ts.map +1 -0
- package/dist/components/move-chart-dialog.jsx +68 -0
- package/dist/components/move-chart-dialog.jsx.map +1 -0
- package/dist/components/reference-multi-table-dialog.d.ts +12 -0
- package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
- package/dist/components/reference-multi-table-dialog.jsx +126 -0
- package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
- package/dist/components/related-records-tabs.d.ts +8 -0
- package/dist/components/related-records-tabs.d.ts.map +1 -0
- package/dist/components/related-records-tabs.jsx +75 -0
- package/dist/components/related-records-tabs.jsx.map +1 -0
- package/dist/components/revisions-button.d.ts +7 -0
- package/dist/components/revisions-button.d.ts.map +1 -0
- package/dist/components/revisions-button.jsx +152 -0
- package/dist/components/revisions-button.jsx.map +1 -0
- package/dist/components/wizard-form.d.ts +43 -0
- package/dist/components/wizard-form.d.ts.map +1 -0
- package/dist/components/wizard-form.jsx +136 -0
- package/dist/components/wizard-form.jsx.map +1 -0
- package/dist/dashboard/time-series.d.ts +20 -0
- package/dist/dashboard/time-series.d.ts.map +1 -0
- package/dist/dashboard/time-series.js +108 -0
- package/dist/dashboard/time-series.js.map +1 -0
- package/dist/dialogs.d.ts +35 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.jsx +152 -0
- package/dist/dialogs.jsx.map +1 -0
- package/dist/export.d.ts +39 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +114 -0
- package/dist/export.js.map +1 -0
- package/dist/extension-registry.d.ts +122 -0
- package/dist/extension-registry.d.ts.map +1 -0
- package/dist/extension-registry.js +93 -0
- package/dist/extension-registry.js.map +1 -0
- package/dist/header-controls.d.ts +4 -0
- package/dist/header-controls.d.ts.map +1 -0
- package/dist/header-controls.jsx +70 -0
- package/dist/header-controls.jsx.map +1 -0
- package/dist/hooks.d.ts +104 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +374 -0
- package/dist/hooks.js.map +1 -0
- package/dist/hotkey-help.d.ts +3 -0
- package/dist/hotkey-help.d.ts.map +1 -0
- package/dist/hotkey-help.jsx +32 -0
- package/dist/hotkey-help.jsx.map +1 -0
- package/dist/hotkey-registry.d.ts +18 -0
- package/dist/hotkey-registry.d.ts.map +1 -0
- package/dist/hotkey-registry.jsx +34 -0
- package/dist/hotkey-registry.jsx.map +1 -0
- package/dist/i18n.d.ts +74 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.jsx +127 -0
- package/dist/i18n.jsx.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/notify.d.ts +41 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.jsx +58 -0
- package/dist/notify.jsx.map +1 -0
- package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
- package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
- package/dist/pages/ai-assistant-settings-section.jsx +126 -0
- package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
- package/dist/pages/audit-log-page.d.ts +3 -0
- package/dist/pages/audit-log-page.d.ts.map +1 -0
- package/dist/pages/audit-log-page.jsx +354 -0
- package/dist/pages/audit-log-page.jsx.map +1 -0
- package/dist/pages/edit-page.d.ts +7 -0
- package/dist/pages/edit-page.d.ts.map +1 -0
- package/dist/pages/edit-page.jsx +614 -0
- package/dist/pages/edit-page.jsx.map +1 -0
- package/dist/pages/export-dialog.d.ts +11 -0
- package/dist/pages/export-dialog.d.ts.map +1 -0
- package/dist/pages/export-dialog.jsx +102 -0
- package/dist/pages/export-dialog.jsx.map +1 -0
- package/dist/pages/home-page.d.ts +3 -0
- package/dist/pages/home-page.d.ts.map +1 -0
- package/dist/pages/home-page.jsx +211 -0
- package/dist/pages/home-page.jsx.map +1 -0
- package/dist/pages/list-page.d.ts +42 -0
- package/dist/pages/list-page.d.ts.map +1 -0
- package/dist/pages/list-page.jsx +1596 -0
- package/dist/pages/list-page.jsx.map +1 -0
- package/dist/pages/login-page.d.ts +11 -0
- package/dist/pages/login-page.d.ts.map +1 -0
- package/dist/pages/login-page.jsx +157 -0
- package/dist/pages/login-page.jsx.map +1 -0
- package/dist/pages/settings-page.d.ts +5 -0
- package/dist/pages/settings-page.d.ts.map +1 -0
- package/dist/pages/settings-page.jsx +787 -0
- package/dist/pages/settings-page.jsx.map +1 -0
- package/dist/pages/settings-shared.d.ts +51 -0
- package/dist/pages/settings-shared.d.ts.map +1 -0
- package/dist/pages/settings-shared.jsx +66 -0
- package/dist/pages/settings-shared.jsx.map +1 -0
- package/dist/pages/show-page.d.ts +7 -0
- package/dist/pages/show-page.d.ts.map +1 -0
- package/dist/pages/show-page.jsx +147 -0
- package/dist/pages/show-page.jsx.map +1 -0
- package/dist/pages/wizard-create-page.d.ts +14 -0
- package/dist/pages/wizard-create-page.d.ts.map +1 -0
- package/dist/pages/wizard-create-page.jsx +106 -0
- package/dist/pages/wizard-create-page.jsx.map +1 -0
- package/dist/property-renderer.d.ts +8 -0
- package/dist/property-renderer.d.ts.map +1 -0
- package/dist/property-renderer.jsx +690 -0
- package/dist/property-renderer.jsx.map +1 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.jsx +32 -0
- package/dist/provider.jsx.map +1 -0
- package/dist/realtime.d.ts +22 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +38 -0
- package/dist/realtime.js.map +1 -0
- package/dist/reference.d.ts +52 -0
- package/dist/reference.d.ts.map +1 -0
- package/dist/reference.jsx +224 -0
- package/dist/reference.jsx.map +1 -0
- package/dist/relations.d.ts +11 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +36 -0
- package/dist/relations.js.map +1 -0
- package/dist/router.d.ts +82 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.jsx +187 -0
- package/dist/router.jsx.map +1 -0
- package/dist/show-when.d.ts +7 -0
- package/dist/show-when.d.ts.map +1 -0
- package/dist/show-when.js +77 -0
- package/dist/show-when.js.map +1 -0
- package/dist/types.d.ts +194 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/dist/use-dashboard-charts.d.ts +93 -0
- package/dist/use-dashboard-charts.d.ts.map +1 -0
- package/dist/use-dashboard-charts.js +263 -0
- package/dist/use-dashboard-charts.js.map +1 -0
- package/dist/use-hotkey.d.ts +17 -0
- package/dist/use-hotkey.d.ts.map +1 -0
- package/dist/use-hotkey.js +103 -0
- package/dist/use-hotkey.js.map +1 -0
- package/dist/user-directory.d.ts +18 -0
- package/dist/user-directory.d.ts.map +1 -0
- package/dist/user-directory.js +51 -0
- package/dist/user-directory.js.map +1 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +338 -0
- package/dist/validation.js.map +1 -0
- package/package.json +59 -0
- package/src/action-guard.ts +20 -0
- package/src/action-menu.tsx +161 -0
- package/src/admin-app.tsx +630 -0
- package/src/admin-router.tsx +273 -0
- package/src/breadcrumbs.tsx +75 -0
- package/src/client.ts +1093 -0
- package/src/component-loader.ts +33 -0
- package/src/components/ai-assistant-widget.tsx +565 -0
- package/src/components/ai-fill-dialog.tsx +143 -0
- package/src/components/chart-builder-dialog.tsx +618 -0
- package/src/components/chart-widget.tsx +654 -0
- package/src/components/global-search-dialog.tsx +272 -0
- package/src/components/group-settings-dialog.tsx +93 -0
- package/src/components/move-chart-dialog.tsx +130 -0
- package/src/components/reference-multi-table-dialog.tsx +196 -0
- package/src/components/related-records-tabs.tsx +130 -0
- package/src/components/revisions-button.tsx +237 -0
- package/src/components/wizard-form.tsx +302 -0
- package/src/dashboard/time-series.ts +125 -0
- package/src/dialogs.tsx +265 -0
- package/src/export.ts +140 -0
- package/src/extension-registry.ts +195 -0
- package/src/header-controls.tsx +125 -0
- package/src/hooks.ts +509 -0
- package/src/hotkey-help.tsx +56 -0
- package/src/hotkey-registry.tsx +60 -0
- package/src/i18n.tsx +267 -0
- package/src/index.ts +192 -0
- package/src/notify.tsx +94 -0
- package/src/pages/ai-assistant-settings-section.tsx +167 -0
- package/src/pages/audit-log-page.tsx +580 -0
- package/src/pages/edit-page.tsx +743 -0
- package/src/pages/export-dialog.tsx +154 -0
- package/src/pages/home-page.tsx +318 -0
- package/src/pages/list-page.tsx +2645 -0
- package/src/pages/login-page.tsx +242 -0
- package/src/pages/settings-page.tsx +1143 -0
- package/src/pages/settings-shared.tsx +134 -0
- package/src/pages/show-page.tsx +223 -0
- package/src/pages/wizard-create-page.tsx +164 -0
- package/src/property-renderer.tsx +1143 -0
- package/src/provider.tsx +70 -0
- package/src/realtime.ts +55 -0
- package/src/reference.tsx +386 -0
- package/src/relations.ts +55 -0
- package/src/router.tsx +211 -0
- package/src/show-when.ts +76 -0
- package/src/types.ts +198 -0
- package/src/use-dashboard-charts.ts +362 -0
- package/src/use-hotkey.ts +128 -0
- package/src/user-directory.ts +56 -0
- package/src/validation.ts +361 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Time-series UI helpers — bucket fill, tick formatting, label formatting.
|
|
2
|
+
// All zero-fill happens here on the client side (req: "Заполнение нулями
|
|
3
|
+
// должно быть только в ui, вне зависимости от адаптеров"). Adapters return
|
|
4
|
+
// only buckets that have data; the UI fabricates the rest at value 0 so
|
|
5
|
+
// charts don't have visual gaps.
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
TimeSeriesPoint,
|
|
9
|
+
TimeSeriesSeries,
|
|
10
|
+
TimeSeriesStep,
|
|
11
|
+
} from '../client.js'
|
|
12
|
+
|
|
13
|
+
/** Generate a continuous sequence of `YYYY-MM-DD` bucket keys spanning [from, to]. */
|
|
14
|
+
export function generateBuckets(
|
|
15
|
+
fromIso: string,
|
|
16
|
+
toIso: string,
|
|
17
|
+
step: TimeSeriesStep,
|
|
18
|
+
): string[] {
|
|
19
|
+
if (step === 'all') return [fromIso.slice(0, 10)]
|
|
20
|
+
const out: string[] = []
|
|
21
|
+
const cur = startOf(new Date(fromIso), step)
|
|
22
|
+
const end = startOf(new Date(toIso), step)
|
|
23
|
+
while (cur.getTime() <= end.getTime()) {
|
|
24
|
+
out.push(ymd(cur))
|
|
25
|
+
advance(cur, step)
|
|
26
|
+
}
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Zero-fill every series so each one has a value for every bucket in the
|
|
32
|
+
* resolved date range. Missing buckets become `value: 0` rather than gaps,
|
|
33
|
+
* which Recharts renders as flat segments instead of breaks.
|
|
34
|
+
*/
|
|
35
|
+
export function fillTimeSeries(
|
|
36
|
+
series: TimeSeriesSeries[],
|
|
37
|
+
fromIso: string,
|
|
38
|
+
toIso: string,
|
|
39
|
+
step: TimeSeriesStep,
|
|
40
|
+
): TimeSeriesSeries[] {
|
|
41
|
+
const buckets = generateBuckets(fromIso, toIso, step)
|
|
42
|
+
return series.map((s) => {
|
|
43
|
+
const map = new Map(s.points.map((p) => [p.date, p.value]))
|
|
44
|
+
const points: TimeSeriesPoint[] = buckets.map((date) => ({
|
|
45
|
+
date,
|
|
46
|
+
value: map.get(date) ?? 0,
|
|
47
|
+
}))
|
|
48
|
+
return { key: s.key, points }
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* X-axis tick formatter — short label format depending on bucket size.
|
|
54
|
+
* day/week → DD.MM, month → MM.YYYY, year → YYYY.
|
|
55
|
+
*/
|
|
56
|
+
export function makeTickFormatter(
|
|
57
|
+
step: TimeSeriesStep,
|
|
58
|
+
locale = 'en-US',
|
|
59
|
+
): (iso: string) => string {
|
|
60
|
+
const dayMonth = new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit' })
|
|
61
|
+
const monthYear = new Intl.DateTimeFormat(locale, { month: '2-digit', year: 'numeric' })
|
|
62
|
+
const year = new Intl.DateTimeFormat(locale, { year: 'numeric' })
|
|
63
|
+
return (iso: string): string => {
|
|
64
|
+
const d = new Date(iso)
|
|
65
|
+
if (step === 'year') return year.format(d)
|
|
66
|
+
if (step === 'month') return monthYear.format(d)
|
|
67
|
+
return dayMonth.format(d)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Tooltip label formatter — long, human-friendly form. For week buckets
|
|
73
|
+
* we render `Mon DD – Sun DD` (the bucket plus 6 days).
|
|
74
|
+
*/
|
|
75
|
+
export function makeLabelFormatter(
|
|
76
|
+
step: TimeSeriesStep,
|
|
77
|
+
locale = 'en-US',
|
|
78
|
+
): (iso: string) => string {
|
|
79
|
+
const fullDay = new Intl.DateTimeFormat(locale, {
|
|
80
|
+
day: '2-digit',
|
|
81
|
+
month: 'long',
|
|
82
|
+
year: 'numeric',
|
|
83
|
+
})
|
|
84
|
+
const monthYear = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' })
|
|
85
|
+
const year = new Intl.DateTimeFormat(locale, { year: 'numeric' })
|
|
86
|
+
const shortDay = new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short' })
|
|
87
|
+
return (iso: string): string => {
|
|
88
|
+
const d = new Date(iso)
|
|
89
|
+
if (step === 'year') return year.format(d)
|
|
90
|
+
if (step === 'month') return monthYear.format(d)
|
|
91
|
+
if (step === 'week') {
|
|
92
|
+
const end = new Date(d)
|
|
93
|
+
end.setDate(end.getDate() + 6)
|
|
94
|
+
return `${shortDay.format(d)} – ${shortDay.format(end)}`
|
|
95
|
+
}
|
|
96
|
+
return fullDay.format(d)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Internals ────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function ymd(d: Date): string {
|
|
103
|
+
return d.toISOString().slice(0, 10)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function startOf(d: Date, step: TimeSeriesStep): Date {
|
|
107
|
+
const out = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
|
|
108
|
+
if (step === 'week') {
|
|
109
|
+
// Monday-anchored week.
|
|
110
|
+
const dow = (out.getUTCDay() + 6) % 7
|
|
111
|
+
out.setUTCDate(out.getUTCDate() - dow)
|
|
112
|
+
} else if (step === 'month') {
|
|
113
|
+
out.setUTCDate(1)
|
|
114
|
+
} else if (step === 'year') {
|
|
115
|
+
out.setUTCMonth(0, 1)
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function advance(d: Date, step: TimeSeriesStep): void {
|
|
121
|
+
if (step === 'day') d.setUTCDate(d.getUTCDate() + 1)
|
|
122
|
+
else if (step === 'week') d.setUTCDate(d.getUTCDate() + 7)
|
|
123
|
+
else if (step === 'month') d.setUTCMonth(d.getUTCMonth() + 1)
|
|
124
|
+
else if (step === 'year') d.setUTCFullYear(d.getUTCFullYear() + 1)
|
|
125
|
+
}
|
package/src/dialogs.tsx
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Imperative modal dialogs.
|
|
2
|
+
//
|
|
3
|
+
// `<DialogsProvider>` owns a small stack of active dialogs and renders them
|
|
4
|
+
// through shadcn's purpose-built primitives: `<AlertDialog>` for confirm/alert
|
|
5
|
+
// (focus trap + Esc-to-cancel + accessible role), and `<Dialog>` for arbitrary
|
|
6
|
+
// content opened via `open()`. `useDialogs()` exposes a promise-flavoured API
|
|
7
|
+
// (`confirm`, `alert`, `open`) so callers can `await` user choices instead of
|
|
8
|
+
// weaving open/close state through their components. All built-in dialogs go
|
|
9
|
+
// through `useI18n()` for labels — captions stay localized without callers
|
|
10
|
+
// writing translations on the call site.
|
|
11
|
+
//
|
|
12
|
+
// Custom dialogs use `open({ render })`, where `render` receives a `close`
|
|
13
|
+
// callback. The promise resolves with whatever value the caller passes to
|
|
14
|
+
// `close`, defaulting to `undefined` when the user dismisses the dialog
|
|
15
|
+
// (Esc / overlay click).
|
|
16
|
+
|
|
17
|
+
import * as React from 'react'
|
|
18
|
+
import {
|
|
19
|
+
AlertDialog,
|
|
20
|
+
AlertDialogAction,
|
|
21
|
+
AlertDialogCancel,
|
|
22
|
+
AlertDialogContent,
|
|
23
|
+
AlertDialogDescription,
|
|
24
|
+
AlertDialogFooter,
|
|
25
|
+
AlertDialogHeader,
|
|
26
|
+
AlertDialogTitle,
|
|
27
|
+
Dialog,
|
|
28
|
+
DialogContent,
|
|
29
|
+
} from '@modern-admin/ui'
|
|
30
|
+
import { useI18n } from './i18n.js'
|
|
31
|
+
|
|
32
|
+
export interface ConfirmOptions {
|
|
33
|
+
title?: string
|
|
34
|
+
description?: string
|
|
35
|
+
confirmLabel?: string
|
|
36
|
+
cancelLabel?: string
|
|
37
|
+
/** Style the confirm button as destructive (red). Useful for delete dialogs. */
|
|
38
|
+
destructive?: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AlertOptions {
|
|
42
|
+
title?: string
|
|
43
|
+
description?: string
|
|
44
|
+
okLabel?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface OpenOptions<T> {
|
|
48
|
+
render: (api: { close: (value?: T) => void }) => React.ReactNode
|
|
49
|
+
/** Optional max-width override; defaults to `sm:max-w-lg`. */
|
|
50
|
+
className?: string
|
|
51
|
+
/** Disable closing on overlay click + Esc. */
|
|
52
|
+
modal?: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DialogsApi {
|
|
56
|
+
confirm(opts?: ConfirmOptions): Promise<boolean>
|
|
57
|
+
alert(opts?: AlertOptions): Promise<void>
|
|
58
|
+
open<T = unknown>(opts: OpenOptions<T>): Promise<T | undefined>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface BaseEntry {
|
|
62
|
+
id: number
|
|
63
|
+
open: boolean
|
|
64
|
+
resolve(value: unknown): void
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ConfirmEntry extends BaseEntry {
|
|
68
|
+
kind: 'confirm'
|
|
69
|
+
opts: ConfirmOptions
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface AlertEntry extends BaseEntry {
|
|
73
|
+
kind: 'alert'
|
|
74
|
+
opts: AlertOptions
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface CustomEntry extends BaseEntry {
|
|
78
|
+
kind: 'custom'
|
|
79
|
+
className?: string
|
|
80
|
+
modal?: boolean
|
|
81
|
+
render(api: { close: (value?: unknown) => void }): React.ReactNode
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type DialogEntry = ConfirmEntry | AlertEntry | CustomEntry
|
|
85
|
+
|
|
86
|
+
const DialogsContext = React.createContext<DialogsApi | null>(null)
|
|
87
|
+
|
|
88
|
+
let nextId = 1
|
|
89
|
+
|
|
90
|
+
export interface DialogsProviderProps {
|
|
91
|
+
children: React.ReactNode
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function DialogsProvider({ children }: DialogsProviderProps): React.ReactElement {
|
|
95
|
+
const [entries, setEntries] = React.useState<DialogEntry[]>([])
|
|
96
|
+
const { t } = useI18n()
|
|
97
|
+
|
|
98
|
+
const removeEntry = React.useCallback((id: number) => {
|
|
99
|
+
setEntries((prev) => prev.filter((e) => e.id !== id))
|
|
100
|
+
}, [])
|
|
101
|
+
|
|
102
|
+
const closeEntry = React.useCallback(
|
|
103
|
+
(id: number, value: unknown) => {
|
|
104
|
+
setEntries((prev) => {
|
|
105
|
+
const target = prev.find((e) => e.id === id)
|
|
106
|
+
if (target) target.resolve(value)
|
|
107
|
+
// Mark closed so Radix can play the leave animation, then remove.
|
|
108
|
+
return prev.map((e) => (e.id === id ? { ...e, open: false } : e))
|
|
109
|
+
})
|
|
110
|
+
// Cleanup after the close animation ~200ms.
|
|
111
|
+
window.setTimeout(() => removeEntry(id), 250)
|
|
112
|
+
},
|
|
113
|
+
[removeEntry],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
const api = React.useMemo<DialogsApi>(() => {
|
|
117
|
+
const push = <T,>(
|
|
118
|
+
build: (id: number, resolve: (value: T | undefined) => void) => DialogEntry,
|
|
119
|
+
): Promise<T | undefined> =>
|
|
120
|
+
new Promise<T | undefined>((resolve) => {
|
|
121
|
+
const id = nextId++
|
|
122
|
+
const entry = build(id, resolve)
|
|
123
|
+
setEntries((prev) => [...prev, entry])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
confirm: (opts = {}) =>
|
|
128
|
+
push<boolean>((id, resolve) => ({
|
|
129
|
+
kind: 'confirm',
|
|
130
|
+
id,
|
|
131
|
+
open: true,
|
|
132
|
+
opts,
|
|
133
|
+
resolve: (value) => resolve(value as boolean | undefined),
|
|
134
|
+
})).then((v) => v === true),
|
|
135
|
+
alert: (opts = {}) =>
|
|
136
|
+
push<void>((id, resolve) => ({
|
|
137
|
+
kind: 'alert',
|
|
138
|
+
id,
|
|
139
|
+
open: true,
|
|
140
|
+
opts,
|
|
141
|
+
resolve: () => resolve(undefined),
|
|
142
|
+
})).then(() => undefined),
|
|
143
|
+
open: <T,>(opts: OpenOptions<T>) =>
|
|
144
|
+
push<T>((id, resolve) => ({
|
|
145
|
+
kind: 'custom',
|
|
146
|
+
id,
|
|
147
|
+
open: true,
|
|
148
|
+
className: opts.className,
|
|
149
|
+
modal: opts.modal,
|
|
150
|
+
render: ({ close }) => opts.render({ close: (value?: T) => close(value as unknown) }),
|
|
151
|
+
resolve: (value) => resolve(value as T | undefined),
|
|
152
|
+
})),
|
|
153
|
+
}
|
|
154
|
+
}, [])
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<DialogsContext.Provider value={api}>
|
|
158
|
+
{children}
|
|
159
|
+
{entries.map((entry) => {
|
|
160
|
+
if (entry.kind === 'confirm') {
|
|
161
|
+
const title = entry.opts.title ?? t('common:confirmDelete')
|
|
162
|
+
const confirmLabel =
|
|
163
|
+
entry.opts.confirmLabel ?? t(entry.opts.destructive ? 'common:delete' : 'common:save')
|
|
164
|
+
const cancelLabel = entry.opts.cancelLabel ?? t('common:cancel')
|
|
165
|
+
return (
|
|
166
|
+
<AlertDialog
|
|
167
|
+
key={entry.id}
|
|
168
|
+
open={entry.open}
|
|
169
|
+
onOpenChange={(next) => {
|
|
170
|
+
if (!next) closeEntry(entry.id, false)
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
<AlertDialogContent>
|
|
174
|
+
<AlertDialogHeader>
|
|
175
|
+
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
176
|
+
{entry.opts.description && (
|
|
177
|
+
<AlertDialogDescription>{entry.opts.description}</AlertDialogDescription>
|
|
178
|
+
)}
|
|
179
|
+
</AlertDialogHeader>
|
|
180
|
+
<AlertDialogFooter>
|
|
181
|
+
<AlertDialogCancel onClick={() => closeEntry(entry.id, false)}>
|
|
182
|
+
{cancelLabel}
|
|
183
|
+
</AlertDialogCancel>
|
|
184
|
+
<AlertDialogAction
|
|
185
|
+
variant={entry.opts.destructive ? 'destructive' : 'default'}
|
|
186
|
+
onClick={() => closeEntry(entry.id, true)}
|
|
187
|
+
>
|
|
188
|
+
{confirmLabel}
|
|
189
|
+
</AlertDialogAction>
|
|
190
|
+
</AlertDialogFooter>
|
|
191
|
+
</AlertDialogContent>
|
|
192
|
+
</AlertDialog>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
if (entry.kind === 'alert') {
|
|
196
|
+
return (
|
|
197
|
+
<AlertDialog
|
|
198
|
+
key={entry.id}
|
|
199
|
+
open={entry.open}
|
|
200
|
+
onOpenChange={(next) => {
|
|
201
|
+
if (!next) closeEntry(entry.id, undefined)
|
|
202
|
+
}}
|
|
203
|
+
>
|
|
204
|
+
<AlertDialogContent>
|
|
205
|
+
<AlertDialogHeader>
|
|
206
|
+
<AlertDialogTitle>{entry.opts.title ?? ''}</AlertDialogTitle>
|
|
207
|
+
{entry.opts.description && (
|
|
208
|
+
<AlertDialogDescription>{entry.opts.description}</AlertDialogDescription>
|
|
209
|
+
)}
|
|
210
|
+
</AlertDialogHeader>
|
|
211
|
+
<AlertDialogFooter>
|
|
212
|
+
<AlertDialogAction onClick={() => closeEntry(entry.id, undefined)}>
|
|
213
|
+
{entry.opts.okLabel ?? t('common:ok')}
|
|
214
|
+
</AlertDialogAction>
|
|
215
|
+
</AlertDialogFooter>
|
|
216
|
+
</AlertDialogContent>
|
|
217
|
+
</AlertDialog>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
// custom
|
|
221
|
+
return (
|
|
222
|
+
<Dialog
|
|
223
|
+
key={entry.id}
|
|
224
|
+
open={entry.open}
|
|
225
|
+
onOpenChange={(next) => {
|
|
226
|
+
if (!next) closeEntry(entry.id, undefined)
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
<DialogContent
|
|
230
|
+
className={entry.className}
|
|
231
|
+
onInteractOutside={(e) => {
|
|
232
|
+
if (entry.modal) e.preventDefault()
|
|
233
|
+
}}
|
|
234
|
+
onEscapeKeyDown={(e) => {
|
|
235
|
+
if (entry.modal) e.preventDefault()
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
{entry.render({ close: (value) => closeEntry(entry.id, value) })}
|
|
239
|
+
</DialogContent>
|
|
240
|
+
</Dialog>
|
|
241
|
+
)
|
|
242
|
+
})}
|
|
243
|
+
</DialogsContext.Provider>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Imperative dialog API. Falls back to a no-op when no provider is mounted. */
|
|
248
|
+
export function useDialogs(): DialogsApi {
|
|
249
|
+
const ctx = React.useContext(DialogsContext)
|
|
250
|
+
if (ctx) return ctx
|
|
251
|
+
// No provider — fall back to native confirm/alert so basic flows still work
|
|
252
|
+
// when a host app forgets to mount <DialogsProvider>.
|
|
253
|
+
return {
|
|
254
|
+
confirm: async (opts) =>
|
|
255
|
+
typeof window === 'undefined'
|
|
256
|
+
? false
|
|
257
|
+
: window.confirm([opts?.title, opts?.description].filter(Boolean).join('\n\n') || ''),
|
|
258
|
+
alert: async (opts) => {
|
|
259
|
+
if (typeof window !== 'undefined') {
|
|
260
|
+
window.alert([opts?.title, opts?.description].filter(Boolean).join('\n\n') || '')
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
open: async () => undefined,
|
|
264
|
+
}
|
|
265
|
+
}
|
package/src/export.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Client-side export helpers.
|
|
2
|
+
//
|
|
3
|
+
// Pulls every record matching the current list query by paginating with a
|
|
4
|
+
// large pageSize, then serializes to CSV or JSON and triggers a browser
|
|
5
|
+
// download. Pure functions live here so they're trivially unit-testable;
|
|
6
|
+
// the dialog UI in pages/export-dialog.tsx wires them together.
|
|
7
|
+
|
|
8
|
+
import type { AdminClient } from './client.js'
|
|
9
|
+
import type { ListQuery, PropertyJSON, RecordJSON } from './types.js'
|
|
10
|
+
|
|
11
|
+
export type ExportFormat = 'csv' | 'json'
|
|
12
|
+
|
|
13
|
+
export interface FetchAllOptions {
|
|
14
|
+
/** Page size used for each list request. */
|
|
15
|
+
batchSize?: number
|
|
16
|
+
/** Optional progress callback: `(loaded, total)`. */
|
|
17
|
+
onProgress?(loaded: number, total: number): void
|
|
18
|
+
/** AbortSignal — drop pagination loop on cancel. */
|
|
19
|
+
signal?: AbortSignal
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Page through `client.list()` until exhausted, returning every record that
|
|
24
|
+
* matches the same filters/sorting as the current list view. `query.page`
|
|
25
|
+
* and `query.perPage` are overwritten — pass the user's filters/sorting only.
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchAllRecords(
|
|
28
|
+
client: AdminClient,
|
|
29
|
+
resourceId: string,
|
|
30
|
+
query: ListQuery | undefined,
|
|
31
|
+
opts: FetchAllOptions = {},
|
|
32
|
+
): Promise<RecordJSON[]> {
|
|
33
|
+
// Backend caps `perPage` at 200 (see listQueryZ in @modern-admin/nest).
|
|
34
|
+
const batchSize = opts.batchSize ?? 200
|
|
35
|
+
const baseQuery: ListQuery = { ...(query ?? {}), perPage: batchSize, page: 1 }
|
|
36
|
+
const all: RecordJSON[] = []
|
|
37
|
+
let total = 0
|
|
38
|
+
for (let page = 1; ; page++) {
|
|
39
|
+
if (opts.signal?.aborted) throw new DOMException('Aborted', 'AbortError')
|
|
40
|
+
const res = await client.list(resourceId, { ...baseQuery, page })
|
|
41
|
+
total = res.meta.total
|
|
42
|
+
all.push(...res.records)
|
|
43
|
+
opts.onProgress?.(all.length, total)
|
|
44
|
+
if (res.records.length < batchSize) break
|
|
45
|
+
if (all.length >= total) break
|
|
46
|
+
}
|
|
47
|
+
return all
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Escape a single CSV field per RFC 4180 (wrap in quotes when needed). */
|
|
51
|
+
export function csvEscape(value: unknown): string {
|
|
52
|
+
if (value == null) return ''
|
|
53
|
+
let str: string
|
|
54
|
+
if (typeof value === 'string') str = value
|
|
55
|
+
else if (typeof value === 'number' || typeof value === 'boolean') str = String(value)
|
|
56
|
+
else if (value instanceof Date) str = value.toISOString()
|
|
57
|
+
else str = JSON.stringify(value)
|
|
58
|
+
// Quote if it contains comma, quote, CR or LF.
|
|
59
|
+
if (/[",\r\n]/.test(str)) {
|
|
60
|
+
return `"${str.replace(/"/g, '""')}"`
|
|
61
|
+
}
|
|
62
|
+
return str
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SerializeOptions {
|
|
66
|
+
/** Properties to export in this order. Defaults to union of keys in `records`. */
|
|
67
|
+
properties?: PropertyJSON[]
|
|
68
|
+
/** When provided, the active list query is embedded as a comment at the top
|
|
69
|
+
* of the exported file so the export is self-documenting:
|
|
70
|
+
* – CSV: `# Query: {...}` line before the header row
|
|
71
|
+
* – JSON: `// Query: {...}` line before the JSON array */
|
|
72
|
+
query?: ListQuery
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Build a CSV document for the given records. UTF-8 BOM for Excel friendliness. */
|
|
76
|
+
export function recordsToCsv(records: RecordJSON[], opts: SerializeOptions = {}): string {
|
|
77
|
+
const columns = opts.properties
|
|
78
|
+
? opts.properties.map((p) => ({ path: p.path, label: p.label }))
|
|
79
|
+
: columnsFromRecords(records)
|
|
80
|
+
const header = columns.map((c) => csvEscape(c.label)).join(',')
|
|
81
|
+
const lines = records.map((r) =>
|
|
82
|
+
columns.map((c) => csvEscape(r.params[c.path])).join(','),
|
|
83
|
+
)
|
|
84
|
+
const queryComment = opts.query
|
|
85
|
+
? `# Query: ${JSON.stringify(opts.query)}\r\n`
|
|
86
|
+
: ''
|
|
87
|
+
return `\uFEFF${queryComment}${[header, ...lines].join('\r\n')}`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Build a pretty-printed JSON document for the given records.
|
|
91
|
+
* When `opts.query` is set, a `// Query: ...` comment is prepended so the
|
|
92
|
+
* export is self-documenting (JSONC — understood by VS Code, TypeScript, etc.) */
|
|
93
|
+
export function recordsToJson(records: RecordJSON[], opts: SerializeOptions = {}): string {
|
|
94
|
+
const paths = opts.properties?.map((p) => p.path)
|
|
95
|
+
const items = records.map((r) => {
|
|
96
|
+
if (!paths) return { id: r.id, ...r.params }
|
|
97
|
+
const row: Record<string, unknown> = { id: r.id }
|
|
98
|
+
for (const p of paths) row[p] = r.params[p]
|
|
99
|
+
return row
|
|
100
|
+
})
|
|
101
|
+
const json = JSON.stringify(items, null, 2)
|
|
102
|
+
return opts.query ? `// Query: ${JSON.stringify(opts.query)}\n${json}` : json
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function columnsFromRecords(records: RecordJSON[]): { path: string; label: string }[] {
|
|
106
|
+
const seen = new Set<string>()
|
|
107
|
+
const out: { path: string; label: string }[] = []
|
|
108
|
+
for (const r of records) {
|
|
109
|
+
for (const k of Object.keys(r.params)) {
|
|
110
|
+
if (seen.has(k)) continue
|
|
111
|
+
seen.add(k)
|
|
112
|
+
out.push({ path: k, label: k })
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Trigger a browser download for the given text payload. */
|
|
119
|
+
export function downloadText(filename: string, mime: string, body: string): void {
|
|
120
|
+
if (typeof window === 'undefined') return
|
|
121
|
+
const blob = new Blob([body], { type: `${mime};charset=utf-8` })
|
|
122
|
+
const url = URL.createObjectURL(blob)
|
|
123
|
+
const a = document.createElement('a')
|
|
124
|
+
a.href = url
|
|
125
|
+
a.download = filename
|
|
126
|
+
document.body.appendChild(a)
|
|
127
|
+
a.click()
|
|
128
|
+
a.remove()
|
|
129
|
+
// Revoke after a tick so Safari has time to start the download.
|
|
130
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Build a stable filename like `users-20260506-143015.csv`. */
|
|
134
|
+
export function exportFilename(resourceId: string, format: ExportFormat, now: Date = new Date()): string {
|
|
135
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
136
|
+
const stamp =
|
|
137
|
+
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
|
138
|
+
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
139
|
+
return `${resourceId}-${stamp}.${format}`
|
|
140
|
+
}
|