@spree/dashboard-core 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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +418 -0
  3. package/package.json +84 -0
  4. package/src/client.ts +25 -0
  5. package/src/components/address-form-dialog.tsx +376 -0
  6. package/src/components/app-sidebar.tsx +70 -0
  7. package/src/components/bulk-action-bar.tsx +504 -0
  8. package/src/components/can.tsx +30 -0
  9. package/src/components/country-combobox.tsx +238 -0
  10. package/src/components/country-state-fields.tsx +86 -0
  11. package/src/components/currency-select.tsx +181 -0
  12. package/src/components/export-button.tsx +156 -0
  13. package/src/components/file-upload-field.tsx +265 -0
  14. package/src/components/image-upload-field.tsx +53 -0
  15. package/src/components/import-button.tsx +202 -0
  16. package/src/components/locale-select.tsx +268 -0
  17. package/src/components/market-combobox.tsx +89 -0
  18. package/src/components/nav-main.tsx +186 -0
  19. package/src/components/page-header.tsx +286 -0
  20. package/src/components/page-tabs.tsx +78 -0
  21. package/src/components/preferences-form.tsx +404 -0
  22. package/src/components/resource-combobox.tsx +126 -0
  23. package/src/components/resource-multi-autocomplete.tsx +176 -0
  24. package/src/components/resource-picker-sheet.tsx +235 -0
  25. package/src/components/resource-table.tsx +652 -0
  26. package/src/components/settings-sidebar.tsx +139 -0
  27. package/src/components/slot.tsx +50 -0
  28. package/src/components/store-date-picker.tsx +18 -0
  29. package/src/components/store-switcher.tsx +70 -0
  30. package/src/components/table-toolbar.tsx +818 -0
  31. package/src/components/tag-combobox.tsx +99 -0
  32. package/src/components/top-bar.tsx +210 -0
  33. package/src/hooks/use-auth.ts +10 -0
  34. package/src/hooks/use-command-palette.tsx +34 -0
  35. package/src/hooks/use-countries.ts +15 -0
  36. package/src/hooks/use-custom-fields.ts +142 -0
  37. package/src/hooks/use-direct-upload.ts +104 -0
  38. package/src/hooks/use-display-name.ts +42 -0
  39. package/src/hooks/use-export.ts +75 -0
  40. package/src/hooks/use-global-search.ts +79 -0
  41. package/src/hooks/use-host-form.ts +34 -0
  42. package/src/hooks/use-import.ts +62 -0
  43. package/src/hooks/use-resource-mutation.ts +99 -0
  44. package/src/hooks/use-switch-admin-locale.ts +55 -0
  45. package/src/index.ts +97 -0
  46. package/src/lib/custom-field-components.ts +56 -0
  47. package/src/lib/download.ts +49 -0
  48. package/src/lib/filters-to-ransack.ts +47 -0
  49. package/src/lib/form-errors.ts +103 -0
  50. package/src/lib/form-fields-registry.ts +102 -0
  51. package/src/lib/form-mappers.ts +30 -0
  52. package/src/lib/formatters.ts +19 -0
  53. package/src/lib/i18n.ts +210 -0
  54. package/src/lib/nav-registry.ts +224 -0
  55. package/src/lib/permissions.ts +55 -0
  56. package/src/lib/query-client.ts +11 -0
  57. package/src/lib/query-keys.ts +69 -0
  58. package/src/lib/route-registry.ts +158 -0
  59. package/src/lib/search-registry.ts +129 -0
  60. package/src/lib/settings-nav-registry.ts +158 -0
  61. package/src/lib/slot-registry.ts +116 -0
  62. package/src/lib/table-registry.ts +274 -0
  63. package/src/lib/timestamp-columns.tsx +58 -0
  64. package/src/locales/ar.json +629 -0
  65. package/src/locales/de.json +629 -0
  66. package/src/locales/en.json +629 -0
  67. package/src/locales/fr.json +629 -0
  68. package/src/locales/pl.json +629 -0
  69. package/src/locales/zh-CN.json +629 -0
  70. package/src/plugin.ts +222 -0
  71. package/src/providers/auth-provider.tsx +188 -0
  72. package/src/providers/permission-provider.tsx +118 -0
  73. package/src/providers/store-provider.tsx +111 -0
  74. package/src/virtual.d.ts +5 -0
  75. package/src/vite/discover.ts +170 -0
  76. package/src/vite/index.ts +270 -0
@@ -0,0 +1,170 @@
1
+ import fs from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ /**
7
+ * Walk a host project's `package.json` dependencies and return the names of
8
+ * packages that opt-in as dashboard plugins by declaring
9
+ * `"spree": { "dashboard": { "plugin": true } }` in their own `package.json`.
10
+ *
11
+ * Auto-discovery is what makes `pnpm add @acme/foo-plugin` a one-step
12
+ * install: the discovered list feeds both Tailwind source scanning and the
13
+ * `virtual:spree-dashboard-plugins` activation module the app entry imports
14
+ * (see index.ts). Plugin authors opt-in by adding the marker field; hosts
15
+ * never have to know it exists.
16
+ *
17
+ * Order is deterministic — the order packages appear in the host's combined
18
+ * `dependencies` + `devDependencies` maps. Tailwind class scanning is
19
+ * order-insensitive, so this only matters for the dev-mode banner's listing.
20
+ *
21
+ * The function does not throw on missing files or malformed JSON — failures
22
+ * are reported via the `onWarn` callback and the offending package is
23
+ * skipped. A missing host `package.json` returns an empty array.
24
+ */
25
+ export interface DiscoverOptions {
26
+ /** Project root to scan. Usually Vite's `config.root`. */
27
+ root: string
28
+ /** Optional callback for per-package warnings (malformed JSON, missing dep). */
29
+ onWarn?: (message: string) => void
30
+ }
31
+
32
+ /** A discovered plugin plus the build-relevant fields from its marker. */
33
+ export interface DashboardPluginManifest {
34
+ /** Package name, as it appears in the host's dependencies. */
35
+ name: string
36
+ /**
37
+ * Absolute path to the plugin's file-routes directory, when the marker
38
+ * declares one (`"spree": { "dashboard": { "routes": "./src/routes" } }`).
39
+ * Compiled into the host's route tree by `@spree/dashboard/vite`.
40
+ */
41
+ routesDir?: string
42
+ }
43
+
44
+ /**
45
+ * Like {@link discoverDashboardPlugins}, but returns each plugin's manifest
46
+ * fields. `names` restricts resolution to an explicit whitelist (the
47
+ * `plugins:` option) instead of walking the host's dependencies.
48
+ */
49
+ export function discoverDashboardPluginManifests(
50
+ { root, onWarn }: DiscoverOptions,
51
+ names?: string[],
52
+ ): DashboardPluginManifest[] {
53
+ const require = createRequire(path.join(root, 'package.json'))
54
+ const candidates = names ?? discoverDashboardPlugins({ root, onWarn })
55
+
56
+ const manifests: DashboardPluginManifest[] = []
57
+ for (const name of candidates) {
58
+ const manifestPath = resolveManifest(name, require)
59
+ if (!manifestPath) {
60
+ manifests.push({ name })
61
+ continue
62
+ }
63
+ let pkg: PluginManifest
64
+ try {
65
+ pkg = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as PluginManifest
66
+ } catch (err) {
67
+ onWarn?.(`Could not parse ${manifestPath}: ${(err as Error).message}`)
68
+ manifests.push({ name })
69
+ continue
70
+ }
71
+ const routes = pkg.spree?.dashboard?.routes
72
+ manifests.push({
73
+ name,
74
+ routesDir: routes ? path.resolve(path.dirname(manifestPath), routes) : undefined,
75
+ })
76
+ }
77
+ return manifests
78
+ }
79
+
80
+ export function discoverDashboardPlugins({ root, onWarn }: DiscoverOptions): string[] {
81
+ const hostPkgPath = path.join(root, 'package.json')
82
+ if (!fs.existsSync(hostPkgPath)) return []
83
+
84
+ let hostPkg: HostManifest
85
+ try {
86
+ hostPkg = JSON.parse(fs.readFileSync(hostPkgPath, 'utf8')) as HostManifest
87
+ } catch (err) {
88
+ onWarn?.(`Could not parse ${hostPkgPath}: ${(err as Error).message}`)
89
+ return []
90
+ }
91
+
92
+ const deps = [
93
+ ...Object.keys(hostPkg.dependencies ?? {}),
94
+ ...Object.keys(hostPkg.devDependencies ?? {}),
95
+ ]
96
+
97
+ // Resolve from the host's own root so workspace symlinks and `pnpm`'s
98
+ // `.pnpm/` hoisting both work. createRequire wants a file path, not a
99
+ // directory, so synthesize one.
100
+ const require = createRequire(path.join(root, 'package.json'))
101
+
102
+ const discovered: string[] = []
103
+ const seen = new Set<string>()
104
+
105
+ for (const name of deps) {
106
+ if (seen.has(name)) continue
107
+ seen.add(name)
108
+
109
+ const manifestPath = resolveManifest(name, require)
110
+ if (!manifestPath) continue
111
+
112
+ let depPkg: PluginManifest
113
+ try {
114
+ depPkg = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as PluginManifest
115
+ } catch (err) {
116
+ onWarn?.(`Could not parse ${manifestPath}: ${(err as Error).message}`)
117
+ continue
118
+ }
119
+
120
+ if (depPkg.spree?.dashboard?.plugin === true) {
121
+ discovered.push(name)
122
+ }
123
+ }
124
+
125
+ return discovered
126
+ }
127
+
128
+ interface HostManifest {
129
+ dependencies?: Record<string, string>
130
+ devDependencies?: Record<string, string>
131
+ }
132
+
133
+ interface PluginManifest {
134
+ spree?: {
135
+ dashboard?: {
136
+ plugin?: boolean
137
+ routes?: string
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Resolve `<pkg>/package.json` to its filesystem path. We can't always do
144
+ * `require.resolve('<pkg>/package.json')` because many packages don't expose
145
+ * it in their `exports` map. Fall back to walking up from the resolved entry
146
+ * point — same pattern as the source-dir resolver in `index.ts`.
147
+ */
148
+ function resolveManifest(pkg: string, require: NodeJS.Require): string | null {
149
+ try {
150
+ return require.resolve(`${pkg}/package.json`)
151
+ } catch {
152
+ // exports map didn't include package.json; walk up from the main entry.
153
+ }
154
+
155
+ let entry: string
156
+ try {
157
+ entry = require.resolve(pkg)
158
+ } catch {
159
+ return null
160
+ }
161
+
162
+ let current = path.dirname(entry.startsWith('file:') ? fileURLToPath(entry) : entry)
163
+ const { root } = path.parse(current)
164
+ while (current !== root) {
165
+ const candidate = path.join(current, 'package.json')
166
+ if (fs.existsSync(candidate)) return candidate
167
+ current = path.dirname(current)
168
+ }
169
+ return null
170
+ }
@@ -0,0 +1,270 @@
1
+ import fs from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ // Self-referencing package import (not a relative path): this entry is loaded
6
+ // raw by Node when a host's vite.config.ts imports `@spree/dashboard-core/vite`,
7
+ // and Node's TS type-stripping can't resolve extensionless relative specifiers.
8
+ // The exports-map path resolves to the real file name everywhere.
9
+ import { discoverDashboardPlugins } from '@spree/dashboard-core/vite/discover'
10
+ import tailwindcss from '@tailwindcss/vite'
11
+ import type { Plugin, PluginOption } from 'vite'
12
+
13
+ export interface SpreeDashboardPluginOptions {
14
+ /**
15
+ * Path to the host app's CSS entry file (the one that does
16
+ * `@import "@spree/dashboard-ui/styles.css"`). Resolved relative to the
17
+ * host's project root. The plugin injects `@source` directives into this
18
+ * file so Tailwind v4 scans every Spree dashboard package and every
19
+ * dashboard plugin for class usage.
20
+ *
21
+ * Defaults to `./src/styles.css`, which fits the conventional Vite layout.
22
+ */
23
+ cssEntry?: string
24
+
25
+ /**
26
+ * Names of dashboard plugin packages installed in the host app. Each name
27
+ * must be a resolvable npm package specifier — the same string you'd pass
28
+ * to `import()`. The plugin resolves each one via Node module resolution
29
+ * and tells Tailwind v4 to scan its source files for class usage.
30
+ *
31
+ * When omitted (the typical case), the plugin auto-discovers installed
32
+ * dashboard plugins by walking the host's `package.json` dependencies and
33
+ * picking up anything that declares
34
+ * `"spree": { "dashboard": { "plugin": true } }`. Pass an explicit array
35
+ * to opt out of auto-discovery — useful for whitelisting in tightly
36
+ * controlled environments.
37
+ *
38
+ * Example: `['@my-store/orders-plugin', '@my-store/wishlists-plugin']`.
39
+ */
40
+ plugins?: string[]
41
+ }
42
+
43
+ /**
44
+ * Vite plugin that wires up a Spree dashboard's plugin system and Tailwind v4.
45
+ *
46
+ * Combines three concerns:
47
+ *
48
+ * 1. Plugin activation: serves `virtual:spree-dashboard-plugins`, a
49
+ * synthesized module that side-effect-imports every dashboard plugin
50
+ * (auto-discovered or whitelisted). The app entry imports it once —
51
+ * after that, installing a plugin is just `pnpm add`; its
52
+ * `defineDashboardPlugin` call runs without any host-code edit.
53
+ *
54
+ * 2. Source-scanning: tells Tailwind to scan `@spree/dashboard-core`,
55
+ * `@spree/dashboard-ui`, and every plugin package, by injecting
56
+ * `@source` directives into the host's CSS entry. Tailwind v4
57
+ * doesn't scan `node_modules` by default and only accepts filesystem
58
+ * paths in `@source` directives (not bare package specifiers), so we
59
+ * resolve each package through Node module resolution to get an
60
+ * absolute path that works under any package layout (workspace
61
+ * symlinks, npm tarballs, pnpm's `.pnpm/` hoisting).
62
+ *
63
+ * 3. Tailwind itself: returns `@tailwindcss/vite` bundled alongside the
64
+ * source-injection plugin, in the right order. Hosts don't add
65
+ * `@tailwindcss/vite` separately.
66
+ *
67
+ * Usage from a host using the full `@spree/dashboard` app shell:
68
+ *
69
+ * // host vite.config.ts
70
+ * import { spreeDashboardPlugin } from '@spree/dashboard-core/vite'
71
+ *
72
+ * export default defineConfig({
73
+ * plugins: [
74
+ * spreeDashboardPlugin(),
75
+ * // … react(), TanStack Router, etc. — host owns these.
76
+ * ],
77
+ * })
78
+ *
79
+ * // host src/main.tsx (once, before the router mounts)
80
+ * import 'virtual:spree-dashboard-plugins'
81
+ *
82
+ * For a custom dashboard built on `@spree/dashboard-core` + `@spree/dashboard-ui`,
83
+ * point `cssEntry` at the host's own CSS file:
84
+ *
85
+ * spreeDashboardPlugin({
86
+ * cssEntry: './src/admin.css',
87
+ * plugins: ['@my-store/vendor-portal-plugin'], // explicit whitelist opt-out
88
+ * })
89
+ */
90
+ export function spreeDashboardPlugin(options: SpreeDashboardPluginOptions = {}): PluginOption[] {
91
+ return [dashboardTailwindSourcePlugin(options), tailwindcss()]
92
+ }
93
+
94
+ /** Module specifier hosts import to activate discovered dashboard plugins. */
95
+ export const VIRTUAL_PLUGIN_MODULE_ID = 'virtual:spree-dashboard-plugins'
96
+ // Rollup convention: \0 marks the resolved id as virtual so other plugins
97
+ // (and Vite's own resolver) leave it alone.
98
+ const RESOLVED_VIRTUAL_PLUGIN_MODULE_ID = `\0${VIRTUAL_PLUGIN_MODULE_ID}`
99
+
100
+ /**
101
+ * Source of `virtual:spree-dashboard-plugins`: one side-effect import per
102
+ * activatable plugin, in discovery order. Exported for tests.
103
+ */
104
+ export function generateVirtualPluginModule(packages: readonly string[]): string {
105
+ const header = '// generated by @spree/dashboard-core/vite — one import per dashboard plugin'
106
+ if (packages.length === 0) return `${header}\nexport {}\n`
107
+ const imports = packages.map((pkg) => `import ${JSON.stringify(pkg)}`).join('\n')
108
+ return `${header}\n${imports}\nexport {}\n`
109
+ }
110
+
111
+ function dashboardTailwindSourcePlugin(options: SpreeDashboardPluginOptions): Plugin {
112
+ const explicitPlugins = options.plugins
113
+ const cssEntry = options.cssEntry ?? './src/styles.css'
114
+
115
+ // Resolution root for the framework's own siblings. Wherever
116
+ // `@spree/dashboard-core` ends up on disk (workspace symlink, hoisted
117
+ // npm install, pnpm `.pnpm/` directory), `@spree/dashboard-ui` is
118
+ // reachable from here via Node module resolution.
119
+ const fromHere = createRequire(import.meta.url)
120
+
121
+ let resolvedSources: string[] = []
122
+ let resolvedCssEntry = ''
123
+ let pluginErrors: PluginError[] = []
124
+ let activatablePlugins: string[] = []
125
+
126
+ return {
127
+ name: 'spree:dashboard-tailwind-source',
128
+ enforce: 'pre',
129
+
130
+ configResolved(config) {
131
+ resolvedCssEntry = path.resolve(config.root, cssEntry).split(path.sep).join('/')
132
+
133
+ // `plugins: []` is an explicit empty whitelist; only `plugins` omitted
134
+ // triggers discovery. Discovery walks the host's package.json deps and
135
+ // returns names that opt-in via the `spree.dashboard.plugin` marker.
136
+ const pluginPackages =
137
+ explicitPlugins ??
138
+ discoverDashboardPlugins({
139
+ root: config.root,
140
+ onWarn: (msg) => console.warn(`[@spree/dashboard-core/vite] ${msg}`),
141
+ })
142
+
143
+ // Plugins are the host's dependencies, so resolve them from the host
144
+ // root — under pnpm's strict layout they are not necessarily reachable
145
+ // from dashboard-core's own location.
146
+ const fromHost = createRequire(path.join(config.root, 'package.json'))
147
+
148
+ pluginErrors = []
149
+ resolvedSources = []
150
+ activatablePlugins = []
151
+ for (const pkg of ['@spree/dashboard-core', '@spree/dashboard-ui']) {
152
+ const dir = resolvePackageSourceDir(pkg, fromHere)
153
+ if (dir) {
154
+ resolvedSources.push(dir)
155
+ } else {
156
+ pluginErrors.push({ pkg, kind: 'unresolved' })
157
+ }
158
+ }
159
+ // The app shell is optional: present when the host builds on the full
160
+ // `@spree/dashboard` package, absent for custom dashboards composed
161
+ // directly from core + ui — hence no error when unresolved.
162
+ const shellDir = resolvePackageSourceDir('@spree/dashboard', fromHost)
163
+ if (shellDir) resolvedSources.push(shellDir)
164
+ for (const pkg of pluginPackages) {
165
+ const dir = resolvePackageSourceDir(pkg, fromHost)
166
+ if (dir) {
167
+ resolvedSources.push(dir)
168
+ activatablePlugins.push(pkg)
169
+ } else {
170
+ // Unresolved plugins are reported via the dev overlay instead of
171
+ // being imported — a bare failing import would crash the dev
172
+ // server with a much less actionable error.
173
+ pluginErrors.push({ pkg, kind: 'unresolved' })
174
+ }
175
+ }
176
+ },
177
+
178
+ resolveId(id) {
179
+ if (id === VIRTUAL_PLUGIN_MODULE_ID) return RESOLVED_VIRTUAL_PLUGIN_MODULE_ID
180
+ return null
181
+ },
182
+
183
+ load(id) {
184
+ if (id !== RESOLVED_VIRTUAL_PLUGIN_MODULE_ID) return null
185
+ return generateVirtualPluginModule(activatablePlugins)
186
+ },
187
+
188
+ transform(code, id) {
189
+ const normalized = id.split('?')[0].replaceAll('\\', '/')
190
+ if (normalized !== resolvedCssEntry) return null
191
+
192
+ const directives = resolvedSources.map((dir) => `@source '${dir}';`).join('\n')
193
+
194
+ return {
195
+ code: `${code}\n\n/* injected by @spree/dashboard-core/vite */\n${directives}\n`,
196
+ map: null,
197
+ }
198
+ },
199
+
200
+ configureServer(server) {
201
+ // Surface unresolved plugins as a Vite error overlay in dev so the
202
+ // failure mode is loud. Without this, a missing plugin silently emits
203
+ // unstyled HTML when the host imports it. Sent once per client
204
+ // connection rather than per HTTP request.
205
+ if (pluginErrors.length === 0) return
206
+ const message = formatPluginErrorOverlay(pluginErrors)
207
+ server.ws.on('connection', () => {
208
+ server.ws.send({
209
+ type: 'error',
210
+ err: {
211
+ message,
212
+ stack: '',
213
+ plugin: 'spree:dashboard-tailwind-source',
214
+ },
215
+ })
216
+ })
217
+ },
218
+ }
219
+ }
220
+
221
+ interface PluginError {
222
+ pkg: string
223
+ kind: 'unresolved'
224
+ }
225
+
226
+ function formatPluginErrorOverlay(errors: PluginError[]): string {
227
+ const lines = [
228
+ 'Spree dashboard plugin(s) could not be resolved:',
229
+ '',
230
+ ...errors.map(({ pkg }) => ` - ${pkg}`),
231
+ '',
232
+ 'For each one, check:',
233
+ ' 1. The package is installed (`pnpm add <package>`)',
234
+ ' 2. Its package.json declares the dashboard marker:',
235
+ ' { "spree": { "dashboard": { "plugin": true } } }',
236
+ '',
237
+ 'If you registered it explicitly via spreeDashboardPlugin({ plugins: [...] }),',
238
+ 'verify the name matches the installed package exactly.',
239
+ ]
240
+ return lines.join('\n')
241
+ }
242
+
243
+ /**
244
+ * Resolve `<package>/src` on disk. Resolves the package's main entry point
245
+ * (defined by its `exports` map or `main` field) and walks upwards looking
246
+ * for `package.json` to find the package root. Avoids requiring
247
+ * `<pkg>/package.json` to be in the package's `exports` map — many packages,
248
+ * including ours, don't list it there.
249
+ */
250
+ function resolvePackageSourceDir(pkg: string, require: NodeJS.Require): string | null {
251
+ try {
252
+ const entryPath = require.resolve(pkg)
253
+ const entryFile = entryPath.startsWith('file:') ? fileURLToPath(entryPath) : entryPath
254
+ const packageRoot = findPackageRoot(entryFile)
255
+ if (!packageRoot) return null
256
+ return path.join(packageRoot, 'src')
257
+ } catch {
258
+ return null
259
+ }
260
+ }
261
+
262
+ function findPackageRoot(fromFile: string): string | null {
263
+ let current = path.dirname(fromFile)
264
+ const { root } = path.parse(current)
265
+ while (current !== root) {
266
+ if (fs.existsSync(path.join(current, 'package.json'))) return current
267
+ current = path.dirname(current)
268
+ }
269
+ return null
270
+ }