@opensaas/stack-ui 0.1.7 → 0.4.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/.turbo/turbo-build.log +3 -2
- package/CHANGELOG.md +140 -0
- package/dist/components/AdminUI.d.ts +5 -4
- package/dist/components/AdminUI.d.ts.map +1 -1
- package/dist/components/AdminUI.js +2 -2
- package/dist/components/Dashboard.d.ts +4 -4
- package/dist/components/Dashboard.d.ts.map +1 -1
- package/dist/components/Dashboard.js +4 -3
- package/dist/components/ItemForm.d.ts +4 -4
- package/dist/components/ItemForm.d.ts.map +1 -1
- package/dist/components/ItemForm.js +9 -5
- package/dist/components/ItemFormClient.d.ts.map +1 -1
- package/dist/components/ItemFormClient.js +78 -60
- package/dist/components/ListView.d.ts +4 -4
- package/dist/components/ListView.d.ts.map +1 -1
- package/dist/components/ListView.js +18 -11
- package/dist/components/Navigation.d.ts +5 -4
- package/dist/components/Navigation.d.ts.map +1 -1
- package/dist/components/Navigation.js +3 -2
- package/dist/components/UserMenu.d.ts +11 -0
- package/dist/components/UserMenu.d.ts.map +1 -0
- package/dist/components/UserMenu.js +18 -0
- package/dist/components/fields/TextField.d.ts +2 -1
- package/dist/components/fields/TextField.d.ts.map +1 -1
- package/dist/components/fields/TextField.js +4 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/primitives/button.d.ts +1 -1
- package/dist/primitives/button.d.ts.map +1 -1
- package/dist/styles/globals.css +24 -0
- package/package.json +32 -23
- package/src/components/AdminUI.tsx +8 -5
- package/src/components/Dashboard.tsx +7 -10
- package/src/components/ItemForm.tsx +14 -10
- package/src/components/ItemFormClient.tsx +84 -62
- package/src/components/ListView.tsx +23 -21
- package/src/components/Navigation.tsx +14 -25
- package/src/components/UserMenu.tsx +44 -0
- package/src/components/fields/TextField.tsx +7 -2
- package/src/index.ts +2 -0
- package/src/primitives/button.tsx +1 -2
- package/tests/browser/README.md +154 -0
- package/tests/browser/fields/CheckboxField.browser.test.tsx +245 -0
- package/tests/browser/fields/SelectField.browser.test.tsx +263 -0
- package/tests/browser/fields/TextField.browser.test.tsx +204 -0
- package/tests/browser/fields/__screenshots__/CheckboxField.browser.test.tsx/CheckboxField--Browser--edit-mode-should-not-be-clickable-when-disabled-1.png +0 -0
- package/tests/browser/fields/__screenshots__/CheckboxField.browser.test.tsx/CheckboxField--Browser--edit-mode-should-toggle-state-with-multiple-clicks-1.png +0 -0
- package/tests/browser/fields/__screenshots__/TextField.browser.test.tsx/TextField--Browser--edit-mode-should-handle-special-characters-1.png +0 -0
- package/tests/browser/fields/__screenshots__/TextField.browser.test.tsx/TextField--Browser--edit-mode-should-support-copy-and-paste-1.png +0 -0
- package/tests/browser/primitives/Button.browser.test.tsx +122 -0
- package/tests/browser/primitives/Dialog.browser.test.tsx +279 -0
- package/tests/browser/primitives/__screenshots__/Button.browser.test.tsx/Button--Browser--should-not-trigger-click-when-disabled-1.png +0 -0
- package/tests/components/CheckboxField.test.tsx +130 -0
- package/tests/components/DeleteButton.test.tsx +331 -0
- package/tests/components/IntegerField.test.tsx +147 -0
- package/tests/components/ListTable.test.tsx +457 -0
- package/tests/components/ListViewClient.test.tsx +415 -0
- package/tests/components/SearchBar.test.tsx +254 -0
- package/tests/components/SelectField.test.tsx +192 -0
- package/vitest.config.ts +20 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useRouter } from 'next/navigation.js';
|
|
4
|
+
import { Button } from '../primitives/button.js';
|
|
5
|
+
/**
|
|
6
|
+
* User menu component with sign-out button
|
|
7
|
+
* Client Component
|
|
8
|
+
*/
|
|
9
|
+
export function UserMenu({ userName, userEmail, onSignOut }) {
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
const handleSignOut = async () => {
|
|
12
|
+
if (onSignOut) {
|
|
13
|
+
await onSignOut();
|
|
14
|
+
}
|
|
15
|
+
router.push('/sign-in');
|
|
16
|
+
};
|
|
17
|
+
return (_jsxs("div", { className: "p-4 border-t border-border bg-gradient-to-br from-primary/5 to-accent/5", children: [_jsxs("div", { className: "flex items-center space-x-3 mb-3", children: [_jsx("div", { className: "h-9 w-9 rounded-full bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-lg shadow-primary/25", children: _jsx("span", { className: "text-sm font-bold text-primary-foreground", children: userName?.[0]?.toUpperCase() || '?' }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("p", { className: "text-sm font-medium truncate", children: userName || 'User' }), _jsx("p", { className: "text-xs text-muted-foreground truncate", children: userEmail || '' })] })] }), _jsx(Button, { onClick: handleSignOut, variant: "outline", size: "sm", className: "w-full text-sm", children: "Sign Out" })] }));
|
|
18
|
+
}
|
|
@@ -8,6 +8,7 @@ export interface TextFieldProps {
|
|
|
8
8
|
disabled?: boolean;
|
|
9
9
|
required?: boolean;
|
|
10
10
|
mode?: 'read' | 'edit';
|
|
11
|
+
displayMode?: 'input' | 'textarea';
|
|
11
12
|
}
|
|
12
|
-
export declare function TextField({ name, value, onChange, label, placeholder, error, disabled, required, mode, }: TextFieldProps): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
export declare function TextField({ name, value, onChange, label, placeholder, error, disabled, required, mode, displayMode, }: TextFieldProps): import("react/jsx-runtime").JSX.Element;
|
|
13
14
|
//# sourceMappingURL=TextField.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TextField.d.ts","sourceRoot":"","sources":["../../../src/components/fields/TextField.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"TextField.d.ts","sourceRoot":"","sources":["../../../src/components/fields/TextField.tsx"],"names":[],"mappings":"AAOA,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACjC,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,WAAW,CAAC,EAAE,OAAO,GAAG,UAAU,CAAA;CACnC;AAED,wBAAgB,SAAS,CAAC,EACxB,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,KAAK,EACL,WAAW,EACX,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,IAAa,EACb,WAAqB,GACtB,EAAE,cAAc,2CAgChB"}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Input } from '../../primitives/input.js';
|
|
4
|
+
import { Textarea } from '../../primitives/textarea.js';
|
|
4
5
|
import { Label } from '../../primitives/label.js';
|
|
5
6
|
import { cn } from '../../lib/utils.js';
|
|
6
|
-
export function TextField({ name, value, onChange, label, placeholder, error, disabled, required, mode = 'edit', }) {
|
|
7
|
+
export function TextField({ name, value, onChange, label, placeholder, error, disabled, required, mode = 'edit', displayMode = 'input', }) {
|
|
7
8
|
if (mode === 'read') {
|
|
8
9
|
return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-muted-foreground", children: label }), _jsx("p", { className: "text-sm", children: value || '-' })] }));
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
+
const InputComponent = displayMode === 'textarea' ? Textarea : Input;
|
|
12
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { htmlFor: name, children: [label, required && _jsx("span", { className: "text-destructive ml-1", children: "*" })] }), _jsx(InputComponent, { id: name, name: name, type: displayMode === 'input' ? 'text' : undefined, value: value || '', onChange: (e) => onChange(e.target.value), placeholder: placeholder, disabled: disabled, required: required, className: cn(error && 'border-destructive') }), error && _jsx("p", { className: "text-sm text-destructive", children: error })] }));
|
|
11
13
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { AdminUI } from './components/AdminUI.js';
|
|
2
2
|
export { Dashboard } from './components/Dashboard.js';
|
|
3
3
|
export { Navigation } from './components/Navigation.js';
|
|
4
|
+
export { UserMenu } from './components/UserMenu.js';
|
|
4
5
|
export { ListView } from './components/ListView.js';
|
|
5
6
|
export { ListViewClient } from './components/ListViewClient.js';
|
|
6
7
|
export { ItemForm } from './components/ItemForm.js';
|
|
@@ -12,6 +13,7 @@ export { TextField, IntegerField, CheckboxField, SelectField, TimestampField, Pa
|
|
|
12
13
|
export type { AdminUIProps } from './components/AdminUI.js';
|
|
13
14
|
export type { DashboardProps } from './components/Dashboard.js';
|
|
14
15
|
export type { NavigationProps } from './components/Navigation.js';
|
|
16
|
+
export type { UserMenuProps } from './components/UserMenu.js';
|
|
15
17
|
export type { ListViewProps } from './components/ListView.js';
|
|
16
18
|
export type { ListViewClientProps } from './components/ListViewClient.js';
|
|
17
19
|
export type { ItemFormProps } from './components/ItemForm.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAA;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAA;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AAC7D,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAA;AAG5F,OAAO,EACL,SAAS,EACT,YAAY,EACZ,aAAa,EACb,WAAW,EACX,cAAc,EACd,aAAa,EACb,iBAAiB,EACjB,aAAa,EACb,sBAAsB,EACtB,sBAAsB,EACtB,iBAAiB,GAClB,MAAM,8BAA8B,CAAA;AAGrC,YAAY,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAC3D,YAAY,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;AAC/D,YAAY,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AACjE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AACzE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AACzE,YAAY,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAA;AACvE,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AACzE,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AAEzE,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,gBAAgB,EAChB,mBAAmB,EACnB,kBAAkB,EAClB,sBAAsB,EACtB,kBAAkB,EAClB,cAAc,EACd,mBAAmB,GACpB,MAAM,8BAA8B,CAAA;AAGrC,OAAO,EACL,cAAc,EACd,YAAY,EACZ,SAAS,EACT,SAAS,EACT,YAAY,GACb,MAAM,kCAAkC,CAAA;AAEzC,YAAY,EACV,mBAAmB,EACnB,iBAAiB,EACjB,cAAc,EACd,cAAc,EACd,iBAAiB,GAClB,MAAM,kCAAkC,CAAA;AAGzC,OAAO,EAAE,EAAE,EAAE,cAAc,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAG1F,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAA;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAA;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AACnD,OAAO,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AAC7D,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAA;AAG5F,OAAO,EACL,SAAS,EACT,YAAY,EACZ,aAAa,EACb,WAAW,EACX,cAAc,EACd,aAAa,EACb,iBAAiB,EACjB,aAAa,EACb,sBAAsB,EACtB,sBAAsB,EACtB,iBAAiB,GAClB,MAAM,8BAA8B,CAAA;AAGrC,YAAY,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAC3D,YAAY,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;AAC/D,YAAY,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AACjE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC7D,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AACzE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AACzE,YAAY,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAA;AACvE,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AACzE,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AAEzE,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,gBAAgB,EAChB,mBAAmB,EACnB,kBAAkB,EAClB,sBAAsB,EACtB,kBAAkB,EAClB,cAAc,EACd,mBAAmB,GACpB,MAAM,8BAA8B,CAAA;AAGrC,OAAO,EACL,cAAc,EACd,YAAY,EACZ,SAAS,EACT,SAAS,EACT,YAAY,GACb,MAAM,kCAAkC,CAAA;AAEzC,YAAY,EACV,mBAAmB,EACnB,iBAAiB,EACjB,cAAc,EACd,cAAc,EACd,iBAAiB,GAClB,MAAM,kCAAkC,CAAA;AAGzC,OAAO,EAAE,EAAE,EAAE,cAAc,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAG1F,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export { AdminUI } from './components/AdminUI.js';
|
|
3
3
|
export { Dashboard } from './components/Dashboard.js';
|
|
4
4
|
export { Navigation } from './components/Navigation.js';
|
|
5
|
+
export { UserMenu } from './components/UserMenu.js';
|
|
5
6
|
export { ListView } from './components/ListView.js';
|
|
6
7
|
export { ListViewClient } from './components/ListViewClient.js';
|
|
7
8
|
export { ItemForm } from './components/ItemForm.js';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { type VariantProps } from 'class-variance-authority';
|
|
3
3
|
declare const buttonVariants: (props?: ({
|
|
4
|
-
variant?: "
|
|
4
|
+
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | null | undefined;
|
|
5
5
|
size?: "default" | "sm" | "lg" | "icon" | null | undefined;
|
|
6
6
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
7
7
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"button.d.ts","sourceRoot":"","sources":["../../src/primitives/button.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,EAAO,KAAK,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAIjE,QAAA,MAAM,cAAc;;;8EAwBnB,CAAA;AAED,MAAM,WAAW,WACf,SAAQ,KAAK,CAAC,oBAAoB,CAAC,iBAAiB,CAAC,
|
|
1
|
+
{"version":3,"file":"button.d.ts","sourceRoot":"","sources":["../../src/primitives/button.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,EAAO,KAAK,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAIjE,QAAA,MAAM,cAAc;;;8EAwBnB,CAAA;AAED,MAAM,WAAW,WACf,SAAQ,KAAK,CAAC,oBAAoB,CAAC,iBAAiB,CAAC,EAAE,YAAY,CAAC,OAAO,cAAc,CAAC;IAC1F,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,QAAA,MAAM,MAAM,uFAOX,CAAA;AAGD,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,CAAA"}
|
package/dist/styles/globals.css
CHANGED
|
@@ -244,6 +244,9 @@
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
@layer utilities {
|
|
247
|
+
.pointer-events-none {
|
|
248
|
+
pointer-events: none;
|
|
249
|
+
}
|
|
247
250
|
.sr-only {
|
|
248
251
|
position: absolute;
|
|
249
252
|
width: 1px;
|
|
@@ -300,6 +303,24 @@
|
|
|
300
303
|
.z-50 {
|
|
301
304
|
z-index: 50;
|
|
302
305
|
}
|
|
306
|
+
.container {
|
|
307
|
+
width: 100%;
|
|
308
|
+
@media (width >= 40rem) {
|
|
309
|
+
max-width: 40rem;
|
|
310
|
+
}
|
|
311
|
+
@media (width >= 48rem) {
|
|
312
|
+
max-width: 48rem;
|
|
313
|
+
}
|
|
314
|
+
@media (width >= 64rem) {
|
|
315
|
+
max-width: 64rem;
|
|
316
|
+
}
|
|
317
|
+
@media (width >= 80rem) {
|
|
318
|
+
max-width: 80rem;
|
|
319
|
+
}
|
|
320
|
+
@media (width >= 96rem) {
|
|
321
|
+
max-width: 96rem;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
303
324
|
.-mx-1 {
|
|
304
325
|
margin-inline: calc(var(--spacing) * -1);
|
|
305
326
|
}
|
|
@@ -333,6 +354,9 @@
|
|
|
333
354
|
.mb-2 {
|
|
334
355
|
margin-bottom: calc(var(--spacing) * 2);
|
|
335
356
|
}
|
|
357
|
+
.mb-3 {
|
|
358
|
+
margin-bottom: calc(var(--spacing) * 3);
|
|
359
|
+
}
|
|
336
360
|
.mb-4 {
|
|
337
361
|
margin-bottom: calc(var(--spacing) * 4);
|
|
338
362
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opensaas/stack-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Composable React UI components for OpenSaas Stack",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -51,44 +51,50 @@
|
|
|
51
51
|
"url": "https://github.com/OpenSaasAU/stack/issues"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
|
+
"@opensaas/stack-core": "^0",
|
|
54
55
|
"next": "^15.0.0 || ^16.0.0",
|
|
55
56
|
"react": "^19.0.0",
|
|
56
|
-
"react-dom": "^19.0.0"
|
|
57
|
-
"@opensaas/stack-core": "0.1.7"
|
|
57
|
+
"react-dom": "^19.0.0"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"@radix-ui/react-checkbox": "^1.3.3",
|
|
61
61
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
62
62
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
63
|
-
"@radix-ui/react-label": "^2.1.
|
|
63
|
+
"@radix-ui/react-label": "^2.1.8",
|
|
64
64
|
"@radix-ui/react-popover": "^1.1.15",
|
|
65
65
|
"@radix-ui/react-select": "^2.2.6",
|
|
66
|
-
"@radix-ui/react-separator": "^1.1.
|
|
67
|
-
"@radix-ui/react-slot": "^1.2.
|
|
66
|
+
"@radix-ui/react-separator": "^1.1.8",
|
|
67
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
68
68
|
"class-variance-authority": "^0.7.1",
|
|
69
69
|
"clsx": "^2.1.1",
|
|
70
70
|
"date-fns": "^4.1.0",
|
|
71
|
-
"lucide-react": "^0.
|
|
72
|
-
"react-hook-form": "^7.
|
|
73
|
-
"tailwind-merge": "^3.
|
|
71
|
+
"lucide-react": "^0.555.0",
|
|
72
|
+
"react-hook-form": "^7.68.0",
|
|
73
|
+
"tailwind-merge": "^3.4.0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@tailwindcss/postcss": "^4.
|
|
76
|
+
"@tailwindcss/postcss": "^4.1.17",
|
|
77
77
|
"@testing-library/jest-dom": "^6.9.1",
|
|
78
|
-
"@testing-library/react": "^16.
|
|
79
|
-
"@testing-library/user-event": "^14.
|
|
80
|
-
"@types/node": "^24.
|
|
81
|
-
"@types/react": "^19.2.
|
|
82
|
-
"@types/react-dom": "^19.2.
|
|
83
|
-
"@vitejs/plugin-react": "^5.
|
|
84
|
-
"@vitest/
|
|
85
|
-
"
|
|
86
|
-
"
|
|
87
|
-
"
|
|
88
|
-
"
|
|
78
|
+
"@testing-library/react": "^16.3.0",
|
|
79
|
+
"@testing-library/user-event": "^14.6.1",
|
|
80
|
+
"@types/node": "^24.10.1",
|
|
81
|
+
"@types/react": "^19.2.7",
|
|
82
|
+
"@types/react-dom": "^19.2.3",
|
|
83
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
84
|
+
"@vitest/browser": "^4.0.15",
|
|
85
|
+
"@vitest/browser-playwright": "^4.0.15",
|
|
86
|
+
"@vitest/coverage-v8": "^4.0.15",
|
|
87
|
+
"happy-dom": "^20.0.11",
|
|
88
|
+
"next": "^16.0.7",
|
|
89
|
+
"playwright": "^1.57.0",
|
|
90
|
+
"postcss": "^8.5.6",
|
|
91
|
+
"postcss-cli": "^11.0.1",
|
|
92
|
+
"react": "^19.2.1",
|
|
93
|
+
"react-dom": "^19.2.1",
|
|
94
|
+
"tailwindcss": "^4.1.17",
|
|
89
95
|
"typescript": "^5.9.3",
|
|
90
|
-
"vitest": "^4.0.
|
|
91
|
-
"@opensaas/stack-core": "0.
|
|
96
|
+
"vitest": "^4.0.15",
|
|
97
|
+
"@opensaas/stack-core": "0.4.0"
|
|
92
98
|
},
|
|
93
99
|
"scripts": {
|
|
94
100
|
"build": "tsc && npm run build:css",
|
|
@@ -97,6 +103,9 @@
|
|
|
97
103
|
"test": "vitest",
|
|
98
104
|
"test:ui": "vitest --ui",
|
|
99
105
|
"test:coverage": "vitest --coverage",
|
|
106
|
+
"test:browser": "BROWSER_TEST=true vitest --run",
|
|
107
|
+
"test:browser:ui": "BROWSER_TEST=true vitest --ui",
|
|
108
|
+
"test:browser:coverage": "BROWSER_TEST=true vitest --coverage --run",
|
|
100
109
|
"clean": "rm -rf .turbo dist tsconfig.tsbuildinfo"
|
|
101
110
|
}
|
|
102
111
|
}
|
|
@@ -4,17 +4,18 @@ import { Dashboard } from './Dashboard.js'
|
|
|
4
4
|
import { ListView } from './ListView.js'
|
|
5
5
|
import { ItemForm } from './ItemForm.js'
|
|
6
6
|
import type { ServerActionInput } from '../server/types.js'
|
|
7
|
-
import { AccessContext, getListKeyFromUrl, OpenSaasConfig } from '@opensaas/stack-core'
|
|
7
|
+
import { type AccessContext, getListKeyFromUrl, OpenSaasConfig } from '@opensaas/stack-core'
|
|
8
8
|
import { generateThemeCSS } from '../lib/theme.js'
|
|
9
9
|
|
|
10
|
-
export interface AdminUIProps
|
|
11
|
-
context: AccessContext<
|
|
10
|
+
export interface AdminUIProps {
|
|
11
|
+
context: AccessContext<unknown>
|
|
12
12
|
config: OpenSaasConfig
|
|
13
13
|
params?: string[]
|
|
14
14
|
searchParams?: { [key: string]: string | string[] | undefined }
|
|
15
15
|
basePath?: string
|
|
16
16
|
// Server action can return any shape depending on the list item type
|
|
17
17
|
serverAction: (input: ServerActionInput) => Promise<unknown>
|
|
18
|
+
onSignOut?: () => Promise<void>
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -27,14 +28,15 @@ export interface AdminUIProps<TPrisma> {
|
|
|
27
28
|
* - [list, 'create'] → ItemForm (create)
|
|
28
29
|
* - [list, id] → ItemForm (edit)
|
|
29
30
|
*/
|
|
30
|
-
export function AdminUI
|
|
31
|
+
export function AdminUI({
|
|
31
32
|
context,
|
|
32
33
|
config,
|
|
33
34
|
params = [],
|
|
34
35
|
searchParams = {},
|
|
35
36
|
basePath = '/admin',
|
|
36
37
|
serverAction,
|
|
37
|
-
|
|
38
|
+
onSignOut,
|
|
39
|
+
}: AdminUIProps) {
|
|
38
40
|
// Parse route from params
|
|
39
41
|
const [urlSegment, action] = params
|
|
40
42
|
|
|
@@ -104,6 +106,7 @@ export function AdminUI<TPrisma>({
|
|
|
104
106
|
config={config}
|
|
105
107
|
basePath={basePath}
|
|
106
108
|
currentPath={currentPath}
|
|
109
|
+
onSignOut={onSignOut}
|
|
107
110
|
/>
|
|
108
111
|
<main className="flex-1 overflow-y-auto">{content}</main>
|
|
109
112
|
</div>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import Link from 'next/link.js'
|
|
2
2
|
import { formatListName } from '../lib/utils.js'
|
|
3
|
-
import { AccessContext, getDbKey, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
|
|
3
|
+
import { type AccessContext, getDbKey, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
|
|
4
4
|
import { Card, CardContent, CardHeader, CardTitle } from '../primitives/card.js'
|
|
5
5
|
|
|
6
|
-
export interface DashboardProps
|
|
7
|
-
context: AccessContext<
|
|
6
|
+
export interface DashboardProps {
|
|
7
|
+
context: AccessContext<unknown>
|
|
8
8
|
config: OpenSaasConfig
|
|
9
9
|
basePath?: string
|
|
10
10
|
}
|
|
@@ -13,19 +13,16 @@ export interface DashboardProps<TPrisma> {
|
|
|
13
13
|
* Dashboard landing page showing all available lists
|
|
14
14
|
* Server Component
|
|
15
15
|
*/
|
|
16
|
-
export async function Dashboard
|
|
17
|
-
context,
|
|
18
|
-
config,
|
|
19
|
-
basePath = '/admin',
|
|
20
|
-
}: DashboardProps<TPrisma>) {
|
|
16
|
+
export async function Dashboard({ context, config, basePath = '/admin' }: DashboardProps) {
|
|
21
17
|
const lists = Object.keys(config.lists || {})
|
|
22
18
|
|
|
23
19
|
// Get counts for each list
|
|
24
20
|
const listCounts = await Promise.all(
|
|
25
21
|
lists.map(async (listKey) => {
|
|
26
22
|
try {
|
|
27
|
-
const
|
|
28
|
-
|
|
23
|
+
const delegate = context.db[getDbKey(listKey)]
|
|
24
|
+
const count = delegate?.count ? await delegate.count() : 0
|
|
25
|
+
return { listKey, count }
|
|
29
26
|
} catch (error) {
|
|
30
27
|
console.error(`Failed to get count for ${listKey}:`, error)
|
|
31
28
|
return { listKey, count: 0 }
|
|
@@ -3,11 +3,11 @@ import Link from 'next/link.js'
|
|
|
3
3
|
import { ItemFormClient } from './ItemFormClient.js'
|
|
4
4
|
import { formatListName } from '../lib/utils.js'
|
|
5
5
|
import type { ServerActionInput } from '../server/types.js'
|
|
6
|
-
import { AccessContext, getDbKey, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
|
|
6
|
+
import { type AccessContext, getDbKey, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
|
|
7
7
|
import { serializeFieldConfigs } from '../lib/serializeFieldConfig.js'
|
|
8
8
|
|
|
9
|
-
export interface ItemFormProps
|
|
10
|
-
context: AccessContext<
|
|
9
|
+
export interface ItemFormProps {
|
|
10
|
+
context: AccessContext<unknown>
|
|
11
11
|
config: OpenSaasConfig
|
|
12
12
|
listKey: string
|
|
13
13
|
mode: 'create' | 'edit'
|
|
@@ -21,7 +21,7 @@ export interface ItemFormProps<TPrisma> {
|
|
|
21
21
|
* Item form component - create or edit an item
|
|
22
22
|
* Server Component that fetches data and sets up actions
|
|
23
23
|
*/
|
|
24
|
-
export async function ItemForm
|
|
24
|
+
export async function ItemForm({
|
|
25
25
|
context,
|
|
26
26
|
config,
|
|
27
27
|
listKey,
|
|
@@ -29,7 +29,7 @@ export async function ItemForm<TPrisma>({
|
|
|
29
29
|
itemId,
|
|
30
30
|
basePath = '/admin',
|
|
31
31
|
serverAction,
|
|
32
|
-
}: ItemFormProps
|
|
32
|
+
}: ItemFormProps) {
|
|
33
33
|
const listConfig = config.lists[listKey]
|
|
34
34
|
const urlKey = getUrlKey(listKey)
|
|
35
35
|
|
|
@@ -58,10 +58,13 @@ export async function ItemForm<TPrisma>({
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
// Fetch item with relationships included
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
const delegate = context.db[getDbKey(listKey)]
|
|
62
|
+
if (delegate?.findUnique) {
|
|
63
|
+
itemData = await delegate.findUnique({
|
|
64
|
+
where: { id: itemId },
|
|
65
|
+
...(Object.keys(includeRelationships).length > 0 && { include: includeRelationships }),
|
|
66
|
+
})
|
|
67
|
+
}
|
|
65
68
|
} catch (error) {
|
|
66
69
|
console.error(`Failed to fetch item ${itemId}:`, error)
|
|
67
70
|
}
|
|
@@ -102,7 +105,8 @@ export async function ItemForm<TPrisma>({
|
|
|
102
105
|
if (relatedListConfig) {
|
|
103
106
|
try {
|
|
104
107
|
const dbContext = context.db
|
|
105
|
-
const
|
|
108
|
+
const delegate = dbContext[getDbKey(relatedListName)]
|
|
109
|
+
const relatedItems = delegate?.findMany ? await delegate.findMany({}) : []
|
|
106
110
|
|
|
107
111
|
// Use 'name' field as label if it exists, otherwise use 'id'
|
|
108
112
|
relationshipData[fieldName] = relatedItems.map((item: Record<string, unknown>) => ({
|
|
@@ -62,68 +62,81 @@ export function ItemFormClient({
|
|
|
62
62
|
setGeneralError(null)
|
|
63
63
|
|
|
64
64
|
startTransition(async () => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const fieldConfig = fields[fieldName]
|
|
72
|
-
|
|
73
|
-
// Skip password fields that have { isSet: boolean } value (not being changed)
|
|
74
|
-
if (typeof value === 'object' && value !== null && 'isSet' in value) {
|
|
75
|
-
continue
|
|
76
|
-
}
|
|
65
|
+
// Transform relationship fields to Prisma format
|
|
66
|
+
// Filter out password fields with isSet objects (unchanged passwords)
|
|
67
|
+
// File/Image fields: pass File objects through (Next.js will serialize them)
|
|
68
|
+
const transformedData: Record<string, unknown> = {}
|
|
69
|
+
for (const [fieldName, value] of Object.entries(formData)) {
|
|
70
|
+
const fieldConfig = fields[fieldName]
|
|
77
71
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
transformedData[fieldName] = {
|
|
92
|
-
connect: { id: value },
|
|
93
|
-
}
|
|
72
|
+
// Skip password fields that have { isSet: boolean } value (not being changed)
|
|
73
|
+
if (typeof value === 'object' && value !== null && 'isSet' in value) {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Transform relationship fields - check discriminated union type
|
|
78
|
+
const fieldAny = fieldConfig as { type: string; many?: boolean }
|
|
79
|
+
if (fieldAny?.type === 'relationship') {
|
|
80
|
+
if (fieldAny.many) {
|
|
81
|
+
// Many relationship: use connect format
|
|
82
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
83
|
+
transformedData[fieldName] = {
|
|
84
|
+
connect: value.map((id: string) => ({ id })),
|
|
94
85
|
}
|
|
95
86
|
}
|
|
96
87
|
} else {
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
88
|
+
// Single relationship: use connect format
|
|
89
|
+
if (value) {
|
|
90
|
+
transformedData[fieldName] = {
|
|
91
|
+
connect: { id: value },
|
|
92
|
+
}
|
|
93
|
+
}
|
|
100
94
|
}
|
|
95
|
+
} else {
|
|
96
|
+
// Non-relationship field: pass through (including File objects for file/image fields)
|
|
97
|
+
// File objects will be serialized by Next.js server action
|
|
98
|
+
transformedData[fieldName] = value
|
|
101
99
|
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const result =
|
|
103
|
+
mode === 'create'
|
|
104
|
+
? await serverAction({
|
|
105
|
+
listKey,
|
|
106
|
+
action: 'create',
|
|
107
|
+
data: transformedData,
|
|
108
|
+
})
|
|
109
|
+
: await serverAction({
|
|
110
|
+
listKey,
|
|
111
|
+
action: 'update',
|
|
112
|
+
id: itemId!,
|
|
113
|
+
data: transformedData,
|
|
114
|
+
})
|
|
102
115
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
: await serverAction({
|
|
111
|
-
listKey,
|
|
112
|
-
action: 'update',
|
|
113
|
-
id: itemId!,
|
|
114
|
-
data: transformedData,
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
if (result) {
|
|
116
|
+
// Check if result has the new format with success/error fields
|
|
117
|
+
if (result && typeof result === 'object' && 'success' in result) {
|
|
118
|
+
const actionResult = result as
|
|
119
|
+
| { success: true; data: unknown }
|
|
120
|
+
| { success: false; error: string; fieldErrors?: Record<string, string> }
|
|
121
|
+
|
|
122
|
+
if (actionResult.success) {
|
|
118
123
|
// Navigate back to list view
|
|
119
124
|
router.push(`${basePath}/${urlKey}`)
|
|
120
125
|
router.refresh()
|
|
121
126
|
} else {
|
|
122
|
-
|
|
127
|
+
// Handle error response
|
|
128
|
+
if (actionResult.fieldErrors) {
|
|
129
|
+
setErrors(actionResult.fieldErrors)
|
|
130
|
+
}
|
|
131
|
+
setGeneralError(actionResult.error)
|
|
123
132
|
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
} else if (result) {
|
|
134
|
+
// Legacy format: result is the data itself
|
|
135
|
+
router.push(`${basePath}/${urlKey}`)
|
|
136
|
+
router.refresh()
|
|
137
|
+
} else {
|
|
138
|
+
// null result means access denied
|
|
139
|
+
setGeneralError('Access denied or operation failed')
|
|
127
140
|
}
|
|
128
141
|
})
|
|
129
142
|
}
|
|
@@ -135,22 +148,31 @@ export function ItemFormClient({
|
|
|
135
148
|
setShowDeleteConfirm(false)
|
|
136
149
|
|
|
137
150
|
startTransition(async () => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
const result = await serverAction({
|
|
152
|
+
listKey,
|
|
153
|
+
action: 'delete',
|
|
154
|
+
id: itemId,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// Check if result has the new format with success/error fields
|
|
158
|
+
if (result && typeof result === 'object' && 'success' in result) {
|
|
159
|
+
const actionResult = result as
|
|
160
|
+
| { success: true; data: unknown }
|
|
161
|
+
| { success: false; error: string; fieldErrors?: Record<string, string> }
|
|
162
|
+
|
|
163
|
+
if (actionResult.success) {
|
|
146
164
|
router.push(`${basePath}/${urlKey}`)
|
|
147
165
|
router.refresh()
|
|
148
166
|
} else {
|
|
149
|
-
setGeneralError(
|
|
167
|
+
setGeneralError(actionResult.error)
|
|
150
168
|
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
169
|
+
} else if (result) {
|
|
170
|
+
// Legacy format: result is the data itself
|
|
171
|
+
router.push(`${basePath}/${urlKey}`)
|
|
172
|
+
router.refresh()
|
|
173
|
+
} else {
|
|
174
|
+
// null result means access denied
|
|
175
|
+
setGeneralError('Access denied or failed to delete item')
|
|
154
176
|
}
|
|
155
177
|
})
|
|
156
178
|
}
|
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
import Link from 'next/link.js'
|
|
2
2
|
import { ListViewClient } from './ListViewClient.js'
|
|
3
3
|
import { formatListName } from '../lib/utils.js'
|
|
4
|
-
import {
|
|
5
|
-
AccessContext,
|
|
6
|
-
getDbKey,
|
|
7
|
-
getUrlKey,
|
|
8
|
-
OpenSaasConfig,
|
|
9
|
-
type PrismaClientLike,
|
|
10
|
-
} from '@opensaas/stack-core'
|
|
4
|
+
import { type AccessContext, getDbKey, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
|
|
11
5
|
|
|
12
|
-
export interface ListViewProps
|
|
13
|
-
context: AccessContext<
|
|
6
|
+
export interface ListViewProps {
|
|
7
|
+
context: AccessContext<unknown>
|
|
14
8
|
config: OpenSaasConfig
|
|
15
9
|
listKey: string
|
|
16
10
|
basePath?: string
|
|
@@ -24,7 +18,7 @@ export interface ListViewProps<TPrisma extends PrismaClientLike = PrismaClientLi
|
|
|
24
18
|
* List view component - displays items in a table
|
|
25
19
|
* Server Component that fetches data and renders client table
|
|
26
20
|
*/
|
|
27
|
-
export async function ListView
|
|
21
|
+
export async function ListView({
|
|
28
22
|
context,
|
|
29
23
|
config,
|
|
30
24
|
listKey,
|
|
@@ -33,7 +27,7 @@ export async function ListView<TPrisma extends PrismaClientLike = PrismaClientLi
|
|
|
33
27
|
page = 1,
|
|
34
28
|
pageSize = 50,
|
|
35
29
|
search,
|
|
36
|
-
}: ListViewProps
|
|
30
|
+
}: ListViewProps) {
|
|
37
31
|
const key = getDbKey(listKey)
|
|
38
32
|
const urlKey = getUrlKey(listKey)
|
|
39
33
|
const listConfig = config.lists[listKey]
|
|
@@ -86,15 +80,18 @@ export async function ListView<TPrisma extends PrismaClientLike = PrismaClientLi
|
|
|
86
80
|
include[fieldName] = true
|
|
87
81
|
}
|
|
88
82
|
})
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
83
|
+
const delegate = dbContext[key]
|
|
84
|
+
if (delegate?.findMany && delegate?.count) {
|
|
85
|
+
;[items, total] = await Promise.all([
|
|
86
|
+
delegate.findMany({
|
|
87
|
+
where,
|
|
88
|
+
skip,
|
|
89
|
+
take: pageSize,
|
|
90
|
+
...(Object.keys(include).length > 0 ? { include } : {}),
|
|
91
|
+
}),
|
|
92
|
+
delegate.count({ where }),
|
|
93
|
+
])
|
|
94
|
+
}
|
|
98
95
|
} catch (error) {
|
|
99
96
|
console.error(`Failed to fetch ${listKey}:`, error)
|
|
100
97
|
}
|
|
@@ -105,7 +102,12 @@ export async function ListView<TPrisma extends PrismaClientLike = PrismaClientLi
|
|
|
105
102
|
// Extract only the relationship refs needed by client (don't send entire config)
|
|
106
103
|
const relationshipRefs: Record<string, string> = {}
|
|
107
104
|
Object.entries(listConfig.fields).forEach(([fieldName, field]) => {
|
|
108
|
-
if (
|
|
105
|
+
if (
|
|
106
|
+
'type' in field &&
|
|
107
|
+
field.type === 'relationship' &&
|
|
108
|
+
'ref' in field &&
|
|
109
|
+
typeof field.ref === 'string'
|
|
110
|
+
) {
|
|
109
111
|
relationshipRefs[fieldName] = field.ref
|
|
110
112
|
}
|
|
111
113
|
})
|