@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.
Files changed (61) hide show
  1. package/.turbo/turbo-build.log +3 -2
  2. package/CHANGELOG.md +140 -0
  3. package/dist/components/AdminUI.d.ts +5 -4
  4. package/dist/components/AdminUI.d.ts.map +1 -1
  5. package/dist/components/AdminUI.js +2 -2
  6. package/dist/components/Dashboard.d.ts +4 -4
  7. package/dist/components/Dashboard.d.ts.map +1 -1
  8. package/dist/components/Dashboard.js +4 -3
  9. package/dist/components/ItemForm.d.ts +4 -4
  10. package/dist/components/ItemForm.d.ts.map +1 -1
  11. package/dist/components/ItemForm.js +9 -5
  12. package/dist/components/ItemFormClient.d.ts.map +1 -1
  13. package/dist/components/ItemFormClient.js +78 -60
  14. package/dist/components/ListView.d.ts +4 -4
  15. package/dist/components/ListView.d.ts.map +1 -1
  16. package/dist/components/ListView.js +18 -11
  17. package/dist/components/Navigation.d.ts +5 -4
  18. package/dist/components/Navigation.d.ts.map +1 -1
  19. package/dist/components/Navigation.js +3 -2
  20. package/dist/components/UserMenu.d.ts +11 -0
  21. package/dist/components/UserMenu.d.ts.map +1 -0
  22. package/dist/components/UserMenu.js +18 -0
  23. package/dist/components/fields/TextField.d.ts +2 -1
  24. package/dist/components/fields/TextField.d.ts.map +1 -1
  25. package/dist/components/fields/TextField.js +4 -2
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/primitives/button.d.ts +1 -1
  30. package/dist/primitives/button.d.ts.map +1 -1
  31. package/dist/styles/globals.css +24 -0
  32. package/package.json +32 -23
  33. package/src/components/AdminUI.tsx +8 -5
  34. package/src/components/Dashboard.tsx +7 -10
  35. package/src/components/ItemForm.tsx +14 -10
  36. package/src/components/ItemFormClient.tsx +84 -62
  37. package/src/components/ListView.tsx +23 -21
  38. package/src/components/Navigation.tsx +14 -25
  39. package/src/components/UserMenu.tsx +44 -0
  40. package/src/components/fields/TextField.tsx +7 -2
  41. package/src/index.ts +2 -0
  42. package/src/primitives/button.tsx +1 -2
  43. package/tests/browser/README.md +154 -0
  44. package/tests/browser/fields/CheckboxField.browser.test.tsx +245 -0
  45. package/tests/browser/fields/SelectField.browser.test.tsx +263 -0
  46. package/tests/browser/fields/TextField.browser.test.tsx +204 -0
  47. package/tests/browser/fields/__screenshots__/CheckboxField.browser.test.tsx/CheckboxField--Browser--edit-mode-should-not-be-clickable-when-disabled-1.png +0 -0
  48. package/tests/browser/fields/__screenshots__/CheckboxField.browser.test.tsx/CheckboxField--Browser--edit-mode-should-toggle-state-with-multiple-clicks-1.png +0 -0
  49. package/tests/browser/fields/__screenshots__/TextField.browser.test.tsx/TextField--Browser--edit-mode-should-handle-special-characters-1.png +0 -0
  50. package/tests/browser/fields/__screenshots__/TextField.browser.test.tsx/TextField--Browser--edit-mode-should-support-copy-and-paste-1.png +0 -0
  51. package/tests/browser/primitives/Button.browser.test.tsx +122 -0
  52. package/tests/browser/primitives/Dialog.browser.test.tsx +279 -0
  53. package/tests/browser/primitives/__screenshots__/Button.browser.test.tsx/Button--Browser--should-not-trigger-click-when-disabled-1.png +0 -0
  54. package/tests/components/CheckboxField.test.tsx +130 -0
  55. package/tests/components/DeleteButton.test.tsx +331 -0
  56. package/tests/components/IntegerField.test.tsx +147 -0
  57. package/tests/components/ListTable.test.tsx +457 -0
  58. package/tests/components/ListViewClient.test.tsx +415 -0
  59. package/tests/components/SearchBar.test.tsx +254 -0
  60. package/tests/components/SelectField.test.tsx +192 -0
  61. 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":"AAMA,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;CACvB;AAED,wBAAgB,SAAS,CAAC,EACxB,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,KAAK,EACL,WAAW,EACX,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,IAAa,GACd,EAAE,cAAc,2CA8BhB"}
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
- return (_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { htmlFor: name, children: [label, required && _jsx("span", { className: "text-destructive ml-1", children: "*" })] }), _jsx(Input, { id: name, name: name, type: "text", 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
+ 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';
@@ -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?: "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined;
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,EACnD,YAAY,CAAC,OAAO,cAAc,CAAC;IACrC,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,QAAA,MAAM,MAAM,uFAOX,CAAA;AAGD,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,CAAA"}
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"}
@@ -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.1.7",
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.7",
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.7",
67
- "@radix-ui/react-slot": "^1.2.3",
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.553.0",
72
- "react-hook-form": "^7.54.2",
73
- "tailwind-merge": "^3.3.1"
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.0.0",
76
+ "@tailwindcss/postcss": "^4.1.17",
77
77
  "@testing-library/jest-dom": "^6.9.1",
78
- "@testing-library/react": "^16.1.0",
79
- "@testing-library/user-event": "^14.5.2",
80
- "@types/node": "^24.7.2",
81
- "@types/react": "^19.2.2",
82
- "@types/react-dom": "^19.2.2",
83
- "@vitejs/plugin-react": "^5.0.0",
84
- "@vitest/coverage-v8": "^4.0.4",
85
- "happy-dom": "^20.0.0",
86
- "postcss": "^8.4.49",
87
- "postcss-cli": "^11.0.0",
88
- "tailwindcss": "^4.0.0",
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.0",
91
- "@opensaas/stack-core": "0.1.7"
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<TPrisma> {
11
- context: AccessContext<TPrisma>
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<TPrisma>({
31
+ export function AdminUI({
31
32
  context,
32
33
  config,
33
34
  params = [],
34
35
  searchParams = {},
35
36
  basePath = '/admin',
36
37
  serverAction,
37
- }: AdminUIProps<TPrisma>) {
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<TPrisma> {
7
- context: AccessContext<TPrisma>
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<TPrisma>({
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 count = await context.db[getDbKey(listKey)]?.count()
28
- return { listKey, count: count || 0 }
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<TPrisma> {
10
- context: AccessContext<TPrisma>
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<TPrisma>({
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<TPrisma>) {
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
- itemData = await context.db[getDbKey(listKey)].findUnique({
62
- where: { id: itemId },
63
- ...(Object.keys(includeRelationships).length > 0 && { include: includeRelationships }),
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 relatedItems = await dbContext[getDbKey(relatedListName)].findMany({})
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
- try {
66
- // Transform relationship fields to Prisma format
67
- // Filter out password fields with isSet objects (unchanged passwords)
68
- // File/Image fields: pass File objects through (Next.js will serialize them)
69
- const transformedData: Record<string, unknown> = {}
70
- for (const [fieldName, value] of Object.entries(formData)) {
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
- // Transform relationship fields - check discriminated union type
79
- const fieldAny = fieldConfig as { type: string; many?: boolean }
80
- if (fieldAny?.type === 'relationship') {
81
- if (fieldAny.many) {
82
- // Many relationship: use connect format
83
- if (Array.isArray(value) && value.length > 0) {
84
- transformedData[fieldName] = {
85
- connect: value.map((id: string) => ({ id })),
86
- }
87
- }
88
- } else {
89
- // Single relationship: use connect format
90
- if (value) {
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
- // Non-relationship field: pass through (including File objects for file/image fields)
98
- // File objects will be serialized by Next.js server action
99
- transformedData[fieldName] = value
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
- const result =
104
- mode === 'create'
105
- ? await serverAction({
106
- listKey,
107
- action: 'create',
108
- data: transformedData,
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
- setGeneralError('Access denied or operation failed')
127
+ // Handle error response
128
+ if (actionResult.fieldErrors) {
129
+ setErrors(actionResult.fieldErrors)
130
+ }
131
+ setGeneralError(actionResult.error)
123
132
  }
124
- } catch (error) {
125
- const errorMessage = error instanceof Error ? error.message : 'Failed to save item'
126
- setGeneralError(errorMessage)
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
- try {
139
- const result = await serverAction({
140
- listKey,
141
- action: 'delete',
142
- id: itemId,
143
- })
144
-
145
- if (result) {
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('Access denied or failed to delete item')
167
+ setGeneralError(actionResult.error)
150
168
  }
151
- } catch (error) {
152
- const errorMessage = error instanceof Error ? error.message : 'Failed to delete item'
153
- setGeneralError(errorMessage)
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<TPrisma extends PrismaClientLike = PrismaClientLike> {
13
- context: AccessContext<TPrisma>
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<TPrisma extends PrismaClientLike = PrismaClientLike>({
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<TPrisma>) {
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
- ;[items, total] = await Promise.all([
90
- dbContext[key].findMany({
91
- where,
92
- skip,
93
- take: pageSize,
94
- ...(Object.keys(include).length > 0 ? { include } : {}),
95
- }),
96
- dbContext[key].count({ where }),
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 ('type' in field && field.type === 'relationship' && 'ref' in field && field.ref) {
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
  })