create-davepi-ui 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/LICENSE +21 -0
- package/README.md +29 -0
- package/bin/index.js +229 -0
- package/bin/sync-templates.js +100 -0
- package/package.json +40 -0
- package/templates/default/.env.example +1 -0
- package/templates/default/index.html +13 -0
- package/templates/default/package.json +49 -0
- package/templates/default/postcss.config.cjs +6 -0
- package/templates/default/src/App.tsx +42 -0
- package/templates/default/src/components/AppShell.tsx +23 -0
- package/templates/default/src/components/BulkActionBar.tsx +47 -0
- package/templates/default/src/components/RelatedCreateModal.tsx +91 -0
- package/templates/default/src/components/RelatedList.tsx +70 -0
- package/templates/default/src/components/ResourceForm.tsx +311 -0
- package/templates/default/src/components/ResourceTable.tsx +475 -0
- package/templates/default/src/components/RowActions.tsx +54 -0
- package/templates/default/src/components/Sidebar.tsx +171 -0
- package/templates/default/src/components/ui/button.tsx +43 -0
- package/templates/default/src/components/ui/card.tsx +47 -0
- package/templates/default/src/components/ui/checkbox.tsx +24 -0
- package/templates/default/src/components/ui/command.tsx +117 -0
- package/templates/default/src/components/ui/dialog.tsx +95 -0
- package/templates/default/src/components/ui/dropdown-menu.tsx +78 -0
- package/templates/default/src/components/ui/input.tsx +18 -0
- package/templates/default/src/components/ui/label.tsx +17 -0
- package/templates/default/src/components/ui/popover.tsx +27 -0
- package/templates/default/src/components/ui/select.tsx +83 -0
- package/templates/default/src/components/ui/switch.tsx +21 -0
- package/templates/default/src/components/ui/table.tsx +66 -0
- package/templates/default/src/components/ui/tabs.tsx +53 -0
- package/templates/default/src/components/ui/textarea.tsx +17 -0
- package/templates/default/src/davepi-ui.config.ts +14 -0
- package/templates/default/src/index.css +55 -0
- package/templates/default/src/lib/utils.ts +10 -0
- package/templates/default/src/main.tsx +34 -0
- package/templates/default/src/pages/DashboardPage.tsx +42 -0
- package/templates/default/src/pages/LoginScreen.tsx +77 -0
- package/templates/default/src/pages/ResourceCreatePage.tsx +58 -0
- package/templates/default/src/pages/ResourceDetailPage.tsx +171 -0
- package/templates/default/src/pages/ResourceEditPage.tsx +52 -0
- package/templates/default/src/pages/ResourceListPage.tsx +8 -0
- package/templates/default/src/resourceOverrides.ts +34 -0
- package/templates/default/src/resources/account.ts +25 -0
- package/templates/default/src/resources/category.ts +7 -0
- package/templates/default/src/resources/contact.ts +40 -0
- package/templates/default/src/resources/product.ts +7 -0
- package/templates/default/src/resources/project.ts +7 -0
- package/templates/default/src/resources/quote.ts +12 -0
- package/templates/default/src/vite-env.d.ts +9 -0
- package/templates/default/src/widgets/CurrencyInput.tsx +44 -0
- package/templates/default/src/widgets/DateInput.tsx +36 -0
- package/templates/default/src/widgets/EmailInput.tsx +28 -0
- package/templates/default/src/widgets/EnumSelect.tsx +35 -0
- package/templates/default/src/widgets/FileUploaderStub.tsx +9 -0
- package/templates/default/src/widgets/JsonEditor.tsx +64 -0
- package/templates/default/src/widgets/NumberInput.tsx +36 -0
- package/templates/default/src/widgets/RelationPicker.tsx +349 -0
- package/templates/default/src/widgets/SwitchWidget.tsx +20 -0
- package/templates/default/src/widgets/TagInput.tsx +83 -0
- package/templates/default/src/widgets/TextAreaWidget.tsx +27 -0
- package/templates/default/src/widgets/TextInput.tsx +32 -0
- package/templates/default/src/widgets/UrlInput.tsx +27 -0
- package/templates/default/src/widgets/registry.ts +51 -0
- package/templates/default/src/widgets/types.ts +26 -0
- package/templates/default/tailwind.config.ts +54 -0
- package/templates/default/tsconfig.json +40 -0
- package/templates/default/vite.config.ts +16 -0
- package/templates/pinned-versions.json +5 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from 'react';
|
|
2
|
+
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
|
3
|
+
import { cn } from '@/lib/utils';
|
|
4
|
+
|
|
5
|
+
export const Tabs = TabsPrimitive.Root;
|
|
6
|
+
|
|
7
|
+
export const TabsList = forwardRef<
|
|
8
|
+
ElementRef<typeof TabsPrimitive.List>,
|
|
9
|
+
ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
|
10
|
+
>(function TabsList({ className, ...props }, ref) {
|
|
11
|
+
return (
|
|
12
|
+
<TabsPrimitive.List
|
|
13
|
+
ref={ref}
|
|
14
|
+
className={cn(
|
|
15
|
+
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
|
16
|
+
className
|
|
17
|
+
)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const TabsTrigger = forwardRef<
|
|
24
|
+
ElementRef<typeof TabsPrimitive.Trigger>,
|
|
25
|
+
ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
|
26
|
+
>(function TabsTrigger({ className, ...props }, ref) {
|
|
27
|
+
return (
|
|
28
|
+
<TabsPrimitive.Trigger
|
|
29
|
+
ref={ref}
|
|
30
|
+
className={cn(
|
|
31
|
+
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
|
32
|
+
className
|
|
33
|
+
)}
|
|
34
|
+
{...props}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const TabsContent = forwardRef<
|
|
40
|
+
ElementRef<typeof TabsPrimitive.Content>,
|
|
41
|
+
ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
|
42
|
+
>(function TabsContent({ className, ...props }, ref) {
|
|
43
|
+
return (
|
|
44
|
+
<TabsPrimitive.Content
|
|
45
|
+
ref={ref}
|
|
46
|
+
className={cn(
|
|
47
|
+
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
48
|
+
className
|
|
49
|
+
)}
|
|
50
|
+
{...props}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { forwardRef, type TextareaHTMLAttributes } from 'react';
|
|
2
|
+
import { cn } from '@/lib/utils';
|
|
3
|
+
|
|
4
|
+
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
|
5
|
+
function Textarea({ className, ...props }, ref) {
|
|
6
|
+
return (
|
|
7
|
+
<textarea
|
|
8
|
+
ref={ref}
|
|
9
|
+
className={cn(
|
|
10
|
+
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
11
|
+
className
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineConfig } from '@davepi/ui-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* App-wide configuration for the davepi-ui example app shell.
|
|
5
|
+
*
|
|
6
|
+
* Per-resource overrides live in `src/resources/{path}.ts(x)` and merge
|
|
7
|
+
* deeply on top of these defaults. Inline JSX props on `<ResourceTable>`
|
|
8
|
+
* / `<ResourceForm>` win over everything.
|
|
9
|
+
*/
|
|
10
|
+
export default defineConfig({
|
|
11
|
+
apiBaseUrl: (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:4001',
|
|
12
|
+
branding: { name: 'davepi-ui' },
|
|
13
|
+
categoryOrder: ['CRM', 'Catalogue', 'Delivery'],
|
|
14
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
@layer base {
|
|
6
|
+
:root {
|
|
7
|
+
--background: 0 0% 100%;
|
|
8
|
+
--foreground: 240 10% 3.9%;
|
|
9
|
+
--card: 0 0% 100%;
|
|
10
|
+
--card-foreground: 240 10% 3.9%;
|
|
11
|
+
--primary: 240 5.9% 10%;
|
|
12
|
+
--primary-foreground: 0 0% 98%;
|
|
13
|
+
--secondary: 240 4.8% 95.9%;
|
|
14
|
+
--secondary-foreground: 240 5.9% 10%;
|
|
15
|
+
--muted: 240 4.8% 95.9%;
|
|
16
|
+
--muted-foreground: 240 3.8% 46.1%;
|
|
17
|
+
--accent: 240 4.8% 95.9%;
|
|
18
|
+
--accent-foreground: 240 5.9% 10%;
|
|
19
|
+
--destructive: 0 84.2% 60.2%;
|
|
20
|
+
--destructive-foreground: 0 0% 98%;
|
|
21
|
+
--border: 240 5.9% 90%;
|
|
22
|
+
--input: 240 5.9% 90%;
|
|
23
|
+
--ring: 240 5.9% 10%;
|
|
24
|
+
--radius: 0.5rem;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.dark {
|
|
28
|
+
--background: 240 10% 3.9%;
|
|
29
|
+
--foreground: 0 0% 98%;
|
|
30
|
+
--card: 240 10% 3.9%;
|
|
31
|
+
--card-foreground: 0 0% 98%;
|
|
32
|
+
--primary: 0 0% 98%;
|
|
33
|
+
--primary-foreground: 240 5.9% 10%;
|
|
34
|
+
--secondary: 240 3.7% 15.9%;
|
|
35
|
+
--secondary-foreground: 0 0% 98%;
|
|
36
|
+
--muted: 240 3.7% 15.9%;
|
|
37
|
+
--muted-foreground: 240 5% 64.9%;
|
|
38
|
+
--accent: 240 3.7% 15.9%;
|
|
39
|
+
--accent-foreground: 0 0% 98%;
|
|
40
|
+
--destructive: 0 62.8% 30.6%;
|
|
41
|
+
--destructive-foreground: 0 0% 98%;
|
|
42
|
+
--border: 240 3.7% 15.9%;
|
|
43
|
+
--input: 240 3.7% 15.9%;
|
|
44
|
+
--ring: 240 4.9% 83.9%;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
* {
|
|
48
|
+
@apply border-border;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
body {
|
|
52
|
+
@apply bg-background text-foreground;
|
|
53
|
+
font-feature-settings: 'rlig' 1, 'calt' 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
2
|
+
import { twMerge } from 'tailwind-merge';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Combine class names with `clsx` + `tailwind-merge` so conflicting
|
|
6
|
+
* Tailwind utilities resolve to the last-specified one.
|
|
7
|
+
*/
|
|
8
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
9
|
+
return twMerge(clsx(inputs));
|
|
10
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
4
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
5
|
+
import { AuthProvider, ConfigProvider } from '@davepi/ui-react';
|
|
6
|
+
import { App } from './App';
|
|
7
|
+
import davepiConfig from './davepi-ui.config';
|
|
8
|
+
import { resourceOverrides } from './resourceOverrides';
|
|
9
|
+
import './index.css';
|
|
10
|
+
|
|
11
|
+
const queryClient = new QueryClient({
|
|
12
|
+
defaultOptions: {
|
|
13
|
+
queries: {
|
|
14
|
+
retry: 1,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const root = document.getElementById('root');
|
|
20
|
+
if (!root) throw new Error('Root element not found');
|
|
21
|
+
|
|
22
|
+
createRoot(root).render(
|
|
23
|
+
<StrictMode>
|
|
24
|
+
<QueryClientProvider client={queryClient}>
|
|
25
|
+
<ConfigProvider config={davepiConfig} resourceOverrides={resourceOverrides}>
|
|
26
|
+
<AuthProvider baseUrl={davepiConfig.apiBaseUrl}>
|
|
27
|
+
<BrowserRouter>
|
|
28
|
+
<App />
|
|
29
|
+
</BrowserRouter>
|
|
30
|
+
</AuthProvider>
|
|
31
|
+
</ConfigProvider>
|
|
32
|
+
</QueryClientProvider>
|
|
33
|
+
</StrictMode>
|
|
34
|
+
);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useDescribe } from '@davepi/ui-react';
|
|
2
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default home view. Shows a high-level breakdown of registered resources.
|
|
6
|
+
* Consumers swap this out via the per-resource override layer (M3).
|
|
7
|
+
*/
|
|
8
|
+
export function DashboardPage() {
|
|
9
|
+
const { data, isPending, error } = useDescribe();
|
|
10
|
+
return (
|
|
11
|
+
<div className="space-y-6">
|
|
12
|
+
<div>
|
|
13
|
+
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
|
14
|
+
<p className="text-sm text-muted-foreground">
|
|
15
|
+
Resources discovered from <code>/_describe</code>.
|
|
16
|
+
</p>
|
|
17
|
+
</div>
|
|
18
|
+
{isPending ? <p>Loading schema…</p> : null}
|
|
19
|
+
{error ? <p className="text-destructive">{error.message}</p> : null}
|
|
20
|
+
{data ? (
|
|
21
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
22
|
+
{data.registry.paths().map((p) => {
|
|
23
|
+
const display = data.registry.display(p);
|
|
24
|
+
const entry = data.registry.get(p)!;
|
|
25
|
+
return (
|
|
26
|
+
<Card key={p}>
|
|
27
|
+
<CardHeader>
|
|
28
|
+
<CardTitle>{display.pluralLabel}</CardTitle>
|
|
29
|
+
</CardHeader>
|
|
30
|
+
<CardContent className="text-sm text-muted-foreground">
|
|
31
|
+
<div>Path: <code>{p}</code></div>
|
|
32
|
+
<div>Fields: {entry.fields.length}</div>
|
|
33
|
+
<div>Display field: <code>{display.displayField}</code></div>
|
|
34
|
+
</CardContent>
|
|
35
|
+
</Card>
|
|
36
|
+
);
|
|
37
|
+
})}
|
|
38
|
+
</div>
|
|
39
|
+
) : null}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useEffect, useState, type FormEvent } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { useAuth } from '@davepi/ui-react';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { Input } from '@/components/ui/input';
|
|
6
|
+
import { Label } from '@/components/ui/label';
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
8
|
+
|
|
9
|
+
export function LoginScreen() {
|
|
10
|
+
const { login, status } = useAuth();
|
|
11
|
+
const navigate = useNavigate();
|
|
12
|
+
const [email, setEmail] = useState('');
|
|
13
|
+
const [password, setPassword] = useState('');
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
const [submitting, setSubmitting] = useState(false);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (status === 'authenticated') navigate('/', { replace: true });
|
|
19
|
+
}, [navigate, status]);
|
|
20
|
+
|
|
21
|
+
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
|
22
|
+
event.preventDefault();
|
|
23
|
+
setSubmitting(true);
|
|
24
|
+
setError(null);
|
|
25
|
+
try {
|
|
26
|
+
await login(email, password);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
setError(err instanceof Error ? err.message : 'Login failed');
|
|
29
|
+
} finally {
|
|
30
|
+
setSubmitting(false);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
|
36
|
+
<Card className="w-full max-w-md">
|
|
37
|
+
<CardHeader>
|
|
38
|
+
<CardTitle>Sign in to davepi</CardTitle>
|
|
39
|
+
</CardHeader>
|
|
40
|
+
<CardContent>
|
|
41
|
+
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
|
42
|
+
<div className="flex flex-col gap-2">
|
|
43
|
+
<Label htmlFor="email">Email</Label>
|
|
44
|
+
<Input
|
|
45
|
+
id="email"
|
|
46
|
+
type="email"
|
|
47
|
+
autoComplete="email"
|
|
48
|
+
required
|
|
49
|
+
value={email}
|
|
50
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
53
|
+
<div className="flex flex-col gap-2">
|
|
54
|
+
<Label htmlFor="password">Password</Label>
|
|
55
|
+
<Input
|
|
56
|
+
id="password"
|
|
57
|
+
type="password"
|
|
58
|
+
autoComplete="current-password"
|
|
59
|
+
required
|
|
60
|
+
value={password}
|
|
61
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
{error ? (
|
|
65
|
+
<p role="alert" className="text-sm text-destructive">
|
|
66
|
+
{error}
|
|
67
|
+
</p>
|
|
68
|
+
) : null}
|
|
69
|
+
<Button type="submit" disabled={submitting}>
|
|
70
|
+
{submitting ? 'Signing in…' : 'Sign in'}
|
|
71
|
+
</Button>
|
|
72
|
+
</form>
|
|
73
|
+
</CardContent>
|
|
74
|
+
</Card>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { useCreateResource, useDescribe } from '@davepi/ui-react';
|
|
4
|
+
import { ResourceForm } from '@/components/ResourceForm';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create page. Accepts `?prefill_{field}={value}` URL params so a parent
|
|
8
|
+
* resource can hand the child a pre-stamped FK (used by M2 RelatedList).
|
|
9
|
+
* For M1 the FK lands as a hidden+readonly field so the create still works.
|
|
10
|
+
*/
|
|
11
|
+
export function ResourceCreatePage() {
|
|
12
|
+
const params = useParams<{ path: string }>();
|
|
13
|
+
const [searchParams] = useSearchParams();
|
|
14
|
+
const navigate = useNavigate();
|
|
15
|
+
const { data: describe } = useDescribe();
|
|
16
|
+
const path = params.path ?? '';
|
|
17
|
+
const create = useCreateResource(path);
|
|
18
|
+
const [error, setError] = useState<string | null>(null);
|
|
19
|
+
|
|
20
|
+
const display = describe?.registry.display(path);
|
|
21
|
+
const initial: Record<string, unknown> = {};
|
|
22
|
+
const hidden: string[] = [];
|
|
23
|
+
for (const [k, v] of searchParams.entries()) {
|
|
24
|
+
if (!k.startsWith('prefill_')) continue;
|
|
25
|
+
const field = k.slice('prefill_'.length);
|
|
26
|
+
initial[field] = v;
|
|
27
|
+
hidden.push(field);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="mx-auto max-w-2xl space-y-4">
|
|
32
|
+
<header>
|
|
33
|
+
<Link to={`/r/${path}`} className="text-xs text-muted-foreground hover:underline">
|
|
34
|
+
← {display?.pluralLabel ?? path}
|
|
35
|
+
</Link>
|
|
36
|
+
<h1 className="text-2xl font-semibold tracking-tight">New {display?.label ?? path}</h1>
|
|
37
|
+
</header>
|
|
38
|
+
<ResourceForm
|
|
39
|
+
resourcePath={path}
|
|
40
|
+
initial={initial}
|
|
41
|
+
hiddenFields={hidden}
|
|
42
|
+
submitLabel={`Create ${display?.label ?? path}`}
|
|
43
|
+
serverError={error}
|
|
44
|
+
onCancel={() => navigate(`/r/${path}`)}
|
|
45
|
+
onSubmit={async (values) => {
|
|
46
|
+
setError(null);
|
|
47
|
+
try {
|
|
48
|
+
const created = await create.mutateAsync(values);
|
|
49
|
+
const id = (created as { _id?: string })._id;
|
|
50
|
+
navigate(id ? `/r/${path}/${id}` : `/r/${path}`);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
setError(err instanceof Error ? err.message : 'Create failed');
|
|
53
|
+
}
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Link, useNavigate, useParams } from 'react-router-dom';
|
|
3
|
+
import { Pencil, Trash2 } from 'lucide-react';
|
|
4
|
+
import {
|
|
5
|
+
useDeleteResource,
|
|
6
|
+
useDescribe,
|
|
7
|
+
useResource,
|
|
8
|
+
} from '@davepi/ui-react';
|
|
9
|
+
import { Button } from '@/components/ui/button';
|
|
10
|
+
import { labelize } from '@davepi/ui-core';
|
|
11
|
+
import {
|
|
12
|
+
Dialog,
|
|
13
|
+
DialogContent,
|
|
14
|
+
DialogDescription,
|
|
15
|
+
DialogFooter,
|
|
16
|
+
DialogHeader,
|
|
17
|
+
DialogTitle,
|
|
18
|
+
} from '@/components/ui/dialog';
|
|
19
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
20
|
+
import { RelatedList } from '@/components/RelatedList';
|
|
21
|
+
|
|
22
|
+
const SERVER_STAMPED = new Set(['__v', 'userId', 'accountId']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Schema-driven detail page.
|
|
26
|
+
*
|
|
27
|
+
* Renders fields in describe order with type-aware formatting and ships
|
|
28
|
+
* Edit + Delete actions. Every child relation (declared `hasMany`/`hasOne`
|
|
29
|
+
* plus inverse edges synthesised by `SchemaRegistry`) becomes either an
|
|
30
|
+
* embedded `<RelatedList>` (single relation) or a tab (multiple
|
|
31
|
+
* relations) so users can browse and inline-create children without
|
|
32
|
+
* leaving the parent page.
|
|
33
|
+
*/
|
|
34
|
+
export function ResourceDetailPage() {
|
|
35
|
+
const params = useParams<{ path: string; id: string }>();
|
|
36
|
+
const navigate = useNavigate();
|
|
37
|
+
const path = params.path ?? '';
|
|
38
|
+
const id = params.id ?? '';
|
|
39
|
+
const { data: describe } = useDescribe();
|
|
40
|
+
const record = useResource<Record<string, unknown>>(path, id);
|
|
41
|
+
const remove = useDeleteResource(path);
|
|
42
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
43
|
+
|
|
44
|
+
if (!describe) return <p>Loading…</p>;
|
|
45
|
+
const entry = describe.registry.get(path);
|
|
46
|
+
if (!entry) return <p className="text-destructive">Unknown resource: {path}</p>;
|
|
47
|
+
const display = describe.registry.display(path);
|
|
48
|
+
if (record.isPending) return <p>Loading…</p>;
|
|
49
|
+
if (record.error) return <p className="text-destructive">{record.error.message}</p>;
|
|
50
|
+
if (!record.data) return <p>Not found.</p>;
|
|
51
|
+
|
|
52
|
+
const preview = describe.registry.preview(path, record.data);
|
|
53
|
+
const visibleFields = entry.fields.filter((f) => !SERVER_STAMPED.has(f.name));
|
|
54
|
+
const childRelations = describe.registry
|
|
55
|
+
.relations(path)
|
|
56
|
+
.filter((r) => r.kind === 'hasMany' || r.kind === 'hasOne');
|
|
57
|
+
|
|
58
|
+
const detailsBlock = (
|
|
59
|
+
<section className="rounded-md border border-border bg-card">
|
|
60
|
+
<dl className="divide-y divide-border">
|
|
61
|
+
{visibleFields.map((field) => (
|
|
62
|
+
<div key={field.name} className="grid grid-cols-3 gap-4 px-4 py-3">
|
|
63
|
+
<dt className="text-sm text-muted-foreground">
|
|
64
|
+
{labelize(field.name, {
|
|
65
|
+
stripIdSuffix: field.name.endsWith('Id') && field.name !== 'Id',
|
|
66
|
+
})}
|
|
67
|
+
</dt>
|
|
68
|
+
<dd className="col-span-2 text-sm">{formatField(record.data[field.name])}</dd>
|
|
69
|
+
</div>
|
|
70
|
+
))}
|
|
71
|
+
</dl>
|
|
72
|
+
</section>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="space-y-6">
|
|
77
|
+
<header className="flex flex-wrap items-end justify-between gap-3">
|
|
78
|
+
<div>
|
|
79
|
+
<Link to={`/r/${path}`} className="text-xs text-muted-foreground hover:underline">
|
|
80
|
+
← {display.pluralLabel}
|
|
81
|
+
</Link>
|
|
82
|
+
<h1 className="text-2xl font-semibold tracking-tight">{preview}</h1>
|
|
83
|
+
<p className="font-mono text-xs text-muted-foreground">{id}</p>
|
|
84
|
+
</div>
|
|
85
|
+
<div className="flex items-center gap-2">
|
|
86
|
+
<Button asChild variant="outline">
|
|
87
|
+
<Link to={`/r/${path}/${id}/edit`}>
|
|
88
|
+
<Pencil className="mr-1 h-4 w-4" />
|
|
89
|
+
Edit
|
|
90
|
+
</Link>
|
|
91
|
+
</Button>
|
|
92
|
+
<Button variant="destructive" onClick={() => setConfirmOpen(true)}>
|
|
93
|
+
<Trash2 className="mr-1 h-4 w-4" />
|
|
94
|
+
Delete
|
|
95
|
+
</Button>
|
|
96
|
+
</div>
|
|
97
|
+
</header>
|
|
98
|
+
|
|
99
|
+
{childRelations.length === 0 ? (
|
|
100
|
+
detailsBlock
|
|
101
|
+
) : (
|
|
102
|
+
<Tabs defaultValue="details">
|
|
103
|
+
<TabsList>
|
|
104
|
+
<TabsTrigger value="details">Details</TabsTrigger>
|
|
105
|
+
{childRelations.map((rel) => {
|
|
106
|
+
const target = describe.registry.display(rel.target);
|
|
107
|
+
const key = `${rel.target}:${rel.foreignKey}`;
|
|
108
|
+
return (
|
|
109
|
+
<TabsTrigger key={key} value={key}>
|
|
110
|
+
{target.pluralLabel}
|
|
111
|
+
</TabsTrigger>
|
|
112
|
+
);
|
|
113
|
+
})}
|
|
114
|
+
</TabsList>
|
|
115
|
+
<TabsContent value="details" className="mt-4">
|
|
116
|
+
{detailsBlock}
|
|
117
|
+
</TabsContent>
|
|
118
|
+
{childRelations.map((rel) => {
|
|
119
|
+
const key = `${rel.target}:${rel.foreignKey}`;
|
|
120
|
+
return (
|
|
121
|
+
<TabsContent key={key} value={key} className="mt-4">
|
|
122
|
+
<RelatedList
|
|
123
|
+
parentPath={path}
|
|
124
|
+
parentId={id}
|
|
125
|
+
target={rel.target}
|
|
126
|
+
foreignKey={rel.foreignKey}
|
|
127
|
+
/>
|
|
128
|
+
</TabsContent>
|
|
129
|
+
);
|
|
130
|
+
})}
|
|
131
|
+
</Tabs>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
|
135
|
+
<DialogContent>
|
|
136
|
+
<DialogHeader>
|
|
137
|
+
<DialogTitle>Delete {display.label}?</DialogTitle>
|
|
138
|
+
<DialogDescription>
|
|
139
|
+
This action cannot be undone via the UI. Soft-deleted records can be restored from
|
|
140
|
+
the API.
|
|
141
|
+
</DialogDescription>
|
|
142
|
+
</DialogHeader>
|
|
143
|
+
<DialogFooter>
|
|
144
|
+
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
|
145
|
+
Cancel
|
|
146
|
+
</Button>
|
|
147
|
+
<Button
|
|
148
|
+
variant="destructive"
|
|
149
|
+
onClick={async () => {
|
|
150
|
+
await remove.mutateAsync(id);
|
|
151
|
+
setConfirmOpen(false);
|
|
152
|
+
navigate(`/r/${path}`);
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
Delete
|
|
156
|
+
</Button>
|
|
157
|
+
</DialogFooter>
|
|
158
|
+
</DialogContent>
|
|
159
|
+
</Dialog>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function formatField(value: unknown): string {
|
|
165
|
+
if (value == null) return '—';
|
|
166
|
+
if (Array.isArray(value)) return value.length ? value.join(', ') : '—';
|
|
167
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
|
168
|
+
if (value instanceof Date) return value.toLocaleString();
|
|
169
|
+
if (typeof value === 'object') return JSON.stringify(value);
|
|
170
|
+
return String(value);
|
|
171
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Link, useNavigate, useParams } from 'react-router-dom';
|
|
3
|
+
import {
|
|
4
|
+
useDescribe,
|
|
5
|
+
useResource,
|
|
6
|
+
useUpdateResource,
|
|
7
|
+
} from '@davepi/ui-react';
|
|
8
|
+
import { ResourceForm } from '@/components/ResourceForm';
|
|
9
|
+
|
|
10
|
+
export function ResourceEditPage() {
|
|
11
|
+
const params = useParams<{ path: string; id: string }>();
|
|
12
|
+
const navigate = useNavigate();
|
|
13
|
+
const path = params.path ?? '';
|
|
14
|
+
const id = params.id ?? '';
|
|
15
|
+
const { data: describe } = useDescribe();
|
|
16
|
+
const record = useResource<Record<string, unknown>>(path, id);
|
|
17
|
+
const update = useUpdateResource(path);
|
|
18
|
+
const [error, setError] = useState<string | null>(null);
|
|
19
|
+
|
|
20
|
+
const display = describe?.registry.display(path);
|
|
21
|
+
|
|
22
|
+
if (record.isPending) return <p>Loading…</p>;
|
|
23
|
+
if (record.error) return <p className="text-destructive">{record.error.message}</p>;
|
|
24
|
+
if (!record.data) return <p className="text-muted-foreground">Not found.</p>;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="mx-auto max-w-2xl space-y-4">
|
|
28
|
+
<header>
|
|
29
|
+
<Link to={`/r/${path}/${id}`} className="text-xs text-muted-foreground hover:underline">
|
|
30
|
+
← {describe?.registry.preview(path, record.data) ?? id}
|
|
31
|
+
</Link>
|
|
32
|
+
<h1 className="text-2xl font-semibold tracking-tight">Edit {display?.label ?? path}</h1>
|
|
33
|
+
</header>
|
|
34
|
+
<ResourceForm
|
|
35
|
+
resourcePath={path}
|
|
36
|
+
initial={record.data}
|
|
37
|
+
submitLabel="Save changes"
|
|
38
|
+
serverError={error}
|
|
39
|
+
onCancel={() => navigate(`/r/${path}/${id}`)}
|
|
40
|
+
onSubmit={async (values) => {
|
|
41
|
+
setError(null);
|
|
42
|
+
try {
|
|
43
|
+
await update.mutateAsync({ id, body: values });
|
|
44
|
+
navigate(`/r/${path}/${id}`);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
setError(err instanceof Error ? err.message : 'Save failed');
|
|
47
|
+
}
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { useParams } from 'react-router-dom';
|
|
2
|
+
import { ResourceTable } from '@/components/ResourceTable';
|
|
3
|
+
|
|
4
|
+
export function ResourceListPage() {
|
|
5
|
+
const params = useParams<{ path: string }>();
|
|
6
|
+
const path = params.path ?? '';
|
|
7
|
+
return <ResourceTable resourcePath={path} />;
|
|
8
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ResourceConfig } from '@davepi/ui-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Auto-discover per-resource override files at build time via Vite's
|
|
5
|
+
* `import.meta.glob`. Consumers drop files at `src/resources/{path}.ts(x)`
|
|
6
|
+
* exporting a default `ResourceConfig`.
|
|
7
|
+
*
|
|
8
|
+
* Lives at `src/resourceOverrides.ts` (not inside `src/resources/`) so
|
|
9
|
+
* the loader file itself isn't matched by its own glob — that would
|
|
10
|
+
* pull a copy of every resource module into a self-reference and bloat
|
|
11
|
+
* the bundle.
|
|
12
|
+
*
|
|
13
|
+
* Filename → path mapping: `src/resources/account.ts` → `account`.
|
|
14
|
+
* Hyphenated paths supported (`crm-deal.ts` → `crm-deal`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface OverrideModule {
|
|
18
|
+
default?: ResourceConfig;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const modules = import.meta.glob<OverrideModule>('./resources/*.{ts,tsx}', {
|
|
22
|
+
eager: true,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const resourceOverrides: Record<string, ResourceConfig> = Object.fromEntries(
|
|
26
|
+
Object.entries(modules)
|
|
27
|
+
.map(([path, mod]) => {
|
|
28
|
+
const filename = path.replace(/^\.\/resources\//, '').replace(/\.(ts|tsx)$/, '');
|
|
29
|
+
const cfg = mod.default;
|
|
30
|
+
if (!cfg) return null;
|
|
31
|
+
return [filename, cfg] as const;
|
|
32
|
+
})
|
|
33
|
+
.filter((x): x is readonly [string, ResourceConfig] => x !== null)
|
|
34
|
+
);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ResourceConfig } from '@davepi/ui-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Account override. Backend (`/_describe`) supplies label / pluralLabel
|
|
5
|
+
* / displayField, so this file only carries the bits the backend can't
|
|
6
|
+
* know about: sidebar category, table columns, bulk actions.
|
|
7
|
+
*/
|
|
8
|
+
const config: ResourceConfig = {
|
|
9
|
+
category: 'CRM',
|
|
10
|
+
listColumns: [
|
|
11
|
+
{ field: 'accountName', label: 'Account name' },
|
|
12
|
+
{ field: 'description', label: 'Notes' },
|
|
13
|
+
],
|
|
14
|
+
actions: {
|
|
15
|
+
bulk: [
|
|
16
|
+
{
|
|
17
|
+
id: 'bulk-delete',
|
|
18
|
+
label: 'Delete selected',
|
|
19
|
+
kind: 'bulkDelete',
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default config;
|