@actuate-media/cms-admin 0.23.0 → 0.25.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 (62) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +4 -0
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts +2 -0
  5. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js +28 -0
  7. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js.map +1 -0
  8. package/dist/__tests__/views/form-entries.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/form-entries.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/form-entries.render.test.js +112 -0
  11. package/dist/__tests__/views/form-entries.render.test.js.map +1 -0
  12. package/dist/__tests__/views/page-builder-block-renderer.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/page-builder-block-renderer.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/page-builder-block-renderer.render.test.js +48 -0
  15. package/dist/__tests__/views/page-builder-block-renderer.render.test.js.map +1 -0
  16. package/dist/__tests__/views/page-builder-form-picker.render.test.d.ts +2 -0
  17. package/dist/__tests__/views/page-builder-form-picker.render.test.d.ts.map +1 -0
  18. package/dist/__tests__/views/page-builder-form-picker.render.test.js +44 -0
  19. package/dist/__tests__/views/page-builder-form-picker.render.test.js.map +1 -0
  20. package/dist/actuate-admin.css +1 -1
  21. package/dist/components/forms/EntriesTable.d.ts +28 -0
  22. package/dist/components/forms/EntriesTable.d.ts.map +1 -0
  23. package/dist/components/forms/EntriesTable.js +26 -0
  24. package/dist/components/forms/EntriesTable.js.map +1 -0
  25. package/dist/layout/Sidebar.d.ts.map +1 -1
  26. package/dist/layout/Sidebar.js +44 -21
  27. package/dist/layout/Sidebar.js.map +1 -1
  28. package/dist/views/FormEntries.d.ts +5 -0
  29. package/dist/views/FormEntries.d.ts.map +1 -0
  30. package/dist/views/FormEntries.js +211 -0
  31. package/dist/views/FormEntries.js.map +1 -0
  32. package/dist/views/FormSubmissions.d.ts.map +1 -1
  33. package/dist/views/FormSubmissions.js +4 -24
  34. package/dist/views/FormSubmissions.js.map +1 -1
  35. package/dist/views/page-builder/BlockEditor.d.ts.map +1 -1
  36. package/dist/views/page-builder/BlockEditor.js +2 -1
  37. package/dist/views/page-builder/BlockEditor.js.map +1 -1
  38. package/dist/views/page-builder/RelationshipField.d.ts +15 -0
  39. package/dist/views/page-builder/RelationshipField.d.ts.map +1 -0
  40. package/dist/views/page-builder/RelationshipField.js +65 -0
  41. package/dist/views/page-builder/RelationshipField.js.map +1 -0
  42. package/dist/views/page-builder/block-renderers/FormPreview.d.ts +6 -0
  43. package/dist/views/page-builder/block-renderers/FormPreview.d.ts.map +1 -1
  44. package/dist/views/page-builder/block-renderers/FormPreview.js +44 -6
  45. package/dist/views/page-builder/block-renderers/FormPreview.js.map +1 -1
  46. package/dist/views/page-builder/canvas/BlockRenderer.d.ts.map +1 -1
  47. package/dist/views/page-builder/canvas/BlockRenderer.js +3 -9
  48. package/dist/views/page-builder/canvas/BlockRenderer.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/AdminRoot.tsx +4 -0
  51. package/src/__tests__/layout/sidebar-forms-submenu.render.test.tsx +38 -0
  52. package/src/__tests__/views/form-entries.render.test.tsx +133 -0
  53. package/src/__tests__/views/page-builder-block-renderer.render.test.tsx +66 -0
  54. package/src/__tests__/views/page-builder-form-picker.render.test.tsx +54 -0
  55. package/src/components/forms/EntriesTable.tsx +197 -0
  56. package/src/layout/Sidebar.tsx +75 -17
  57. package/src/views/FormEntries.tsx +449 -0
  58. package/src/views/FormSubmissions.tsx +4 -149
  59. package/src/views/page-builder/BlockEditor.tsx +7 -10
  60. package/src/views/page-builder/RelationshipField.tsx +113 -0
  61. package/src/views/page-builder/block-renderers/FormPreview.tsx +85 -33
  62. package/src/views/page-builder/canvas/BlockRenderer.tsx +10 -12
@@ -0,0 +1,113 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useState } from 'react'
4
+ import type { FieldDefinition } from '@actuate-media/cms-core'
5
+ import { cmsApi } from '../../lib/api.js'
6
+
7
+ const INPUT_CLASS =
8
+ 'w-full px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring'
9
+ const LABEL_CLASS = 'text-sm font-medium text-foreground mb-1 block'
10
+
11
+ interface RelationshipOption {
12
+ id: string
13
+ label: string
14
+ }
15
+
16
+ export interface RelationshipFieldProps {
17
+ definition: FieldDefinition
18
+ value: unknown
19
+ onChange: (value: unknown) => void
20
+ label: string
21
+ }
22
+
23
+ /**
24
+ * Relationship picker for page-builder block fields (e.g. the form block's
25
+ * `formId`). Loads the target collection via the admin API and renders a
26
+ * labelled dropdown. If the collection can't be loaded it degrades to a plain
27
+ * id input so the field is never a dead end.
28
+ */
29
+ export function RelationshipField({ definition, value, onChange, label }: RelationshipFieldProps) {
30
+ const relationTo = useMemo(() => {
31
+ if (definition.type !== 'relationship') return ''
32
+ return Array.isArray(definition.relationTo)
33
+ ? (definition.relationTo[0] ?? '')
34
+ : definition.relationTo
35
+ }, [definition])
36
+
37
+ const [options, setOptions] = useState<RelationshipOption[]>([])
38
+ const [loading, setLoading] = useState(false)
39
+ const [failed, setFailed] = useState(false)
40
+
41
+ useEffect(() => {
42
+ if (!relationTo) return
43
+ let cancelled = false
44
+ setLoading(true)
45
+ setFailed(false)
46
+ cmsApi<Array<Record<string, unknown>>>(`/${relationTo}`)
47
+ .then((res) => {
48
+ if (cancelled) return
49
+ if (Array.isArray(res.data)) {
50
+ setOptions(
51
+ res.data
52
+ .map((item) => ({
53
+ id: String(item.id ?? ''),
54
+ label: String(item.name ?? item.title ?? item.slug ?? item.id ?? 'Untitled'),
55
+ }))
56
+ .filter((option) => option.id),
57
+ )
58
+ } else {
59
+ setFailed(true)
60
+ }
61
+ })
62
+ .catch(() => {
63
+ if (!cancelled) setFailed(true)
64
+ })
65
+ .finally(() => {
66
+ if (!cancelled) setLoading(false)
67
+ })
68
+ return () => {
69
+ cancelled = true
70
+ }
71
+ }, [relationTo])
72
+
73
+ const current = (value as string) ?? ''
74
+ const noun = relationTo === 'forms' ? 'a form' : 'an item'
75
+
76
+ if (failed) {
77
+ return (
78
+ <div>
79
+ <label className={LABEL_CLASS}>{label}</label>
80
+ <input
81
+ type="text"
82
+ value={current}
83
+ onChange={(e) => onChange(e.target.value || undefined)}
84
+ placeholder={`Enter ${relationTo} id…`}
85
+ className={INPUT_CLASS}
86
+ />
87
+ </div>
88
+ )
89
+ }
90
+
91
+ return (
92
+ <div>
93
+ <label className={LABEL_CLASS}>{label}</label>
94
+ <select
95
+ value={current}
96
+ onChange={(e) => onChange(e.target.value || undefined)}
97
+ disabled={loading}
98
+ aria-label={label}
99
+ className={INPUT_CLASS}
100
+ >
101
+ <option value="">{loading ? 'Loading…' : `Select ${noun}…`}</option>
102
+ {options.map((option) => (
103
+ <option key={option.id} value={option.id}>
104
+ {option.label}
105
+ </option>
106
+ ))}
107
+ </select>
108
+ {!loading && options.length === 0 && (
109
+ <p className="text-muted-foreground mt-1 text-xs">No {relationTo} found.</p>
110
+ )}
111
+ </div>
112
+ )
113
+ }
@@ -1,17 +1,76 @@
1
1
  'use client'
2
2
 
3
+ import { useEffect, useState } from 'react'
3
4
  import { FileText, Mail, Send } from 'lucide-react'
5
+ import { cmsApi } from '../../../lib/api.js'
4
6
  import type { BlockPreviewProps } from './index.js'
5
7
 
8
+ interface PreviewField {
9
+ key: string
10
+ label: string
11
+ type: string
12
+ }
13
+
14
+ interface PreviewForm {
15
+ name: string
16
+ fields: PreviewField[]
17
+ }
18
+
19
+ /**
20
+ * Canvas preview for the form block. When a form is linked we fetch its real
21
+ * name + fields so the editor sees what will render; otherwise we fall back to
22
+ * a generic skeleton. Network failures degrade to the skeleton rather than
23
+ * breaking the canvas.
24
+ */
6
25
  export function FormPreview({ data, variant = 'contact' }: BlockPreviewProps) {
7
26
  const formId = (data.formId as string) || ''
27
+ const [form, setForm] = useState<PreviewForm | null>(null)
28
+ const [loading, setLoading] = useState(false)
29
+
30
+ useEffect(() => {
31
+ if (!formId) {
32
+ setForm(null)
33
+ return
34
+ }
35
+ let cancelled = false
36
+ setLoading(true)
37
+ cmsApi<{ name?: string; fields?: PreviewField[] }>(`/forms/${encodeURIComponent(formId)}`)
38
+ .then((res) => {
39
+ if (cancelled) return
40
+ if (res.data?.name) {
41
+ setForm({ name: res.data.name, fields: res.data.fields ?? [] })
42
+ } else {
43
+ setForm(null)
44
+ }
45
+ })
46
+ .finally(() => {
47
+ if (!cancelled) setLoading(false)
48
+ })
49
+ return () => {
50
+ cancelled = true
51
+ }
52
+ }, [formId])
53
+
54
+ if (!formId) {
55
+ return (
56
+ <div className="border-border bg-muted/30 rounded-md border border-dashed p-6 text-center">
57
+ <FileText size={20} className="text-muted-foreground mx-auto" />
58
+ <p className="text-muted-foreground mt-2 text-sm">No form selected</p>
59
+ <p className="text-muted-foreground/70 text-xs">Pick a form in the block settings</p>
60
+ </div>
61
+ )
62
+ }
63
+
64
+ const heading = form?.name ?? (loading ? 'Loading form…' : 'Contact Form')
65
+ const fields = form?.fields ?? []
8
66
 
9
67
  if (variant === 'newsletter') {
10
68
  return (
11
69
  <div className="border-border rounded-md border p-6 text-center">
12
70
  <Mail size={24} className="text-muted-foreground mx-auto" />
13
- <p className="text-foreground mt-3 text-sm font-medium">Newsletter Signup</p>
14
- {formId && <p className="text-muted-foreground mt-1 text-xs">Form: {formId}</p>}
71
+ <p className="text-foreground mt-3 text-sm font-medium">
72
+ {form?.name ?? 'Newsletter Signup'}
73
+ </p>
15
74
  <div className="mx-auto mt-4 flex max-w-xs gap-2">
16
75
  <div className="border-border bg-input-background h-9 flex-1 rounded-md border" />
17
76
  <div className="bg-primary flex h-9 items-center rounded-md px-3">
@@ -22,42 +81,35 @@ export function FormPreview({ data, variant = 'contact' }: BlockPreviewProps) {
22
81
  )
23
82
  }
24
83
 
25
- if (variant === 'custom') {
26
- return (
27
- <div className="border-border rounded-md border p-5">
28
- <div className="flex items-center gap-2">
29
- <FileText size={16} className="text-muted-foreground" />
30
- <p className="text-foreground text-sm font-medium">
31
- {formId ? `Form: ${formId}` : 'Custom Form'}
32
- </p>
33
- </div>
34
- <div className="mt-3 flex flex-col gap-2">
35
- <div className="border-border bg-input-background h-8 rounded-md border" />
36
- <div className="border-border bg-input-background h-8 rounded-md border" />
37
- <div className="border-border bg-input-background h-20 rounded-md border" />
38
- </div>
39
- <div className="mt-3">
40
- <span className="bg-primary text-primary-foreground inline-block rounded-md px-4 py-2 text-sm">
41
- Submit
42
- </span>
43
- </div>
44
- </div>
45
- )
46
- }
47
-
48
84
  return (
49
85
  <div className="border-border rounded-md border p-5">
50
- <p className="text-foreground text-sm font-medium">Contact Form</p>
51
- {formId && <p className="text-muted-foreground mt-1 text-xs">Form: {formId}</p>}
52
- <div className="mt-3 grid grid-cols-2 gap-2">
53
- <div className="border-border bg-input-background h-8 rounded-md border" />
54
- <div className="border-border bg-input-background h-8 rounded-md border" />
86
+ <div className="flex items-center gap-2">
87
+ <FileText size={16} className="text-muted-foreground" />
88
+ <p className="text-foreground text-sm font-medium">{heading}</p>
89
+ </div>
90
+ <div className="mt-3 flex flex-col gap-2">
91
+ {fields.length > 0 ? (
92
+ fields.slice(0, 5).map((field) => (
93
+ <div key={field.key}>
94
+ <p className="text-muted-foreground mb-1 text-xs">{field.label || field.key}</p>
95
+ <div
96
+ className={`border-border bg-input-background rounded-md border ${
97
+ field.type === 'textarea' ? 'h-16' : 'h-8'
98
+ }`}
99
+ />
100
+ </div>
101
+ ))
102
+ ) : (
103
+ <>
104
+ <div className="border-border bg-input-background h-8 rounded-md border" />
105
+ <div className="border-border bg-input-background h-8 rounded-md border" />
106
+ <div className="border-border bg-input-background h-20 rounded-md border" />
107
+ </>
108
+ )}
55
109
  </div>
56
- <div className="border-border bg-input-background mt-2 h-8 rounded-md border" />
57
- <div className="border-border bg-input-background mt-2 h-20 rounded-md border" />
58
110
  <div className="mt-3">
59
111
  <span className="bg-primary text-primary-foreground inline-block rounded-md px-4 py-2 text-sm">
60
- Send Message
112
+ {variant === 'custom' ? 'Submit' : 'Send Message'}
61
113
  </span>
62
114
  </div>
63
115
  </div>
@@ -1,8 +1,8 @@
1
1
  'use client'
2
2
 
3
3
  import { useState } from 'react'
4
- import { Box, Type, Image, Layout, Star } from 'lucide-react'
5
4
  import type { BlockNode } from '@actuate-media/cms-core'
5
+ import { blockRenderers, FallbackPreview } from '../block-renderers/index.js'
6
6
 
7
7
  export interface BlockRendererProps {
8
8
  node: BlockNode
@@ -10,13 +10,6 @@ export interface BlockRendererProps {
10
10
  onSelectNode: (id: string | null) => void
11
11
  }
12
12
 
13
- const BLOCK_ICONS: Record<string, typeof Box> = {
14
- hero: Star,
15
- text: Type,
16
- image: Image,
17
- layout: Layout,
18
- }
19
-
20
13
  function getBlockLabel(blockType: string): string {
21
14
  return blockType.charAt(0).toUpperCase() + blockType.slice(1)
22
15
  }
@@ -26,7 +19,7 @@ export function BlockRenderer({ node, selectedNodeId, onSelectNode }: BlockRende
26
19
  const isSelected = selectedNodeId === node.id
27
20
  const blockType = node.settings.blockType
28
21
  const label = getBlockLabel(blockType)
29
- const Icon = BLOCK_ICONS[blockType] ?? Box
22
+ const Preview = blockRenderers[blockType]
30
23
 
31
24
  const handleClick = (e: React.MouseEvent) => {
32
25
  e.stopPropagation()
@@ -36,6 +29,7 @@ export function BlockRenderer({ node, selectedNodeId, onSelectNode }: BlockRende
36
29
  return (
37
30
  <div
38
31
  data-node-id={node.id}
32
+ data-block-type={blockType}
39
33
  className={`relative min-h-[48px] transition-shadow ${
40
34
  isSelected ? 'ring-primary ring-2 ring-offset-2' : hovered ? 'ring-primary/50 ring-1' : ''
41
35
  }`}
@@ -49,9 +43,13 @@ export function BlockRenderer({ node, selectedNodeId, onSelectNode }: BlockRende
49
43
  </span>
50
44
  )}
51
45
 
52
- <div className="bg-muted/30 border-border flex items-center justify-center gap-2 rounded-md border border-dashed p-4">
53
- <Icon size={16} className="text-muted-foreground" />
54
- <span className="text-muted-foreground text-sm">{label}</span>
46
+ {/* Previews are display-only on the canvas; the wrapper owns selection. */}
47
+ <div className="pointer-events-none">
48
+ {Preview ? (
49
+ <Preview data={node.data} variant={node.settings.variant} />
50
+ ) : (
51
+ <FallbackPreview blockType={blockType} data={node.data} />
52
+ )}
55
53
  </div>
56
54
  </div>
57
55
  )