@scaleflex/template-builder 0.1.1

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/src/react.ts ADDED
@@ -0,0 +1,223 @@
1
+ import {
2
+ createElement,
3
+ forwardRef,
4
+ useImperativeHandle,
5
+ useLayoutEffect,
6
+ useRef,
7
+ type CSSProperties,
8
+ type ReactElement,
9
+ } from 'react'
10
+ import './define'
11
+ import type { SfxTemplateBuilder } from './template-builder'
12
+ import type { TemplateBuilderSaveDetail } from './template-builder'
13
+ import type { BuilderDirtyData, BuilderErrorData, BuilderTheme } from './protocol'
14
+
15
+ /**
16
+ * Hub session — the full-featured credential.
17
+ */
18
+ export interface TemplateBuilderSessionAuth {
19
+ sassKey: string
20
+ sessionUuid: string
21
+ companyUuid?: string
22
+ projectUuid?: string
23
+ secTemplate?: never
24
+ }
25
+
26
+ /**
27
+ * Filerobot security template — a guest credential for hosts that have no Hub
28
+ * session to hand over. `stateless` is required rather than merely implied:
29
+ * the app takes a security template on the stateless embed route only, so the
30
+ * combination is a compile-time error instead of a runtime one.
31
+ *
32
+ * `companyUuid` / `projectUuid` are absent by design — they name a Hub project
33
+ * that cannot be looked up without a session.
34
+ */
35
+ export interface TemplateBuilderSecTemplateAuth {
36
+ secTemplate: string
37
+ stateless: true
38
+ sassKey?: never
39
+ sessionUuid?: never
40
+ companyUuid?: never
41
+ projectUuid?: never
42
+ }
43
+
44
+ export interface TemplateBuilderBaseProps {
45
+ baseUrl: string
46
+ token: string
47
+ templateId?: string
48
+ mode?: 'inline' | 'modal'
49
+ /** Hand the template in and take it back out instead of using the DAM. */
50
+ stateless?: boolean
51
+ /** Stateless mode: the template to edit, as `.fdt` XML. */
52
+ content?: string
53
+ /** Stateless mode: display name for the editor header. */
54
+ templateName?: string
55
+ /**
56
+ * Stateless mode: the `template_query` to open on — the value handed back on
57
+ * save. Reopens the template on the same layout and variable values; empty
58
+ * falls back to the XML's own `default=` attributes.
59
+ */
60
+ templateQuery?: string
61
+ /** Accent colour for the editor chrome, as `#rgb` / `#rrggbb`. */
62
+ brandColor?: string
63
+ /** Colour scheme for the editor chrome. */
64
+ theme?: BuilderTheme
65
+ readyTimeout?: number
66
+ className?: string
67
+ style?: CSSProperties
68
+ onReady?: () => void
69
+ onOpen?: () => void
70
+ onClose?: () => void
71
+ /**
72
+ * Fired on save. In stateless mode the detail carries the edited `content`
73
+ * for you to persist; otherwise it reports the uuid the app uploaded to.
74
+ *
75
+ * In stateless mode the outcome is reported back to the editor: return (or
76
+ * resolve to) `false`, or throw, and the editor restores its unsaved-changes
77
+ * flag and tells the user the save failed. Anything else counts as persisted.
78
+ */
79
+ onSave?: (
80
+ data: TemplateBuilderSaveDetail,
81
+ ) => void | boolean | Promise<void | boolean>
82
+ onError?: (data: BuilderErrorData) => void
83
+ /**
84
+ * Stateless mode: unsaved-changes flag changed. Use it to prompt before
85
+ * swapping `content`, which discards in-progress edits.
86
+ */
87
+ onDirtyChange?: (data: BuilderDirtyData) => void
88
+ }
89
+
90
+ export type TemplateBuilderProps = TemplateBuilderBaseProps &
91
+ (TemplateBuilderSessionAuth | TemplateBuilderSecTemplateAuth)
92
+
93
+ /**
94
+ * React wrapper around `<sfx-template-builder>`. Props are assigned as
95
+ * element properties via ref (works on React 18 and 19 alike); callbacks
96
+ * subscribe to the element's CustomEvents.
97
+ *
98
+ * Forwards a ref to the underlying element, which is the only way to reach the
99
+ * imperative API — `open()` in particular, without which `mode="modal"` can
100
+ * never be shown:
101
+ *
102
+ * ```tsx
103
+ * const builder = useRef<SfxTemplateBuilder>(null)
104
+ * <TemplateBuilder ref={builder} mode="modal" … />
105
+ * <button onClick={() => builder.current?.open('tpl-1')}>Edit</button>
106
+ * ```
107
+ *
108
+ * `forwardRef` rather than a plain `ref` prop: React 19 accepts the latter for
109
+ * function components, React 18 does not, and both are supported peers.
110
+ */
111
+ export const TemplateBuilder = forwardRef<
112
+ SfxTemplateBuilder,
113
+ TemplateBuilderProps
114
+ >(function TemplateBuilder(props, forwardedRef): ReactElement {
115
+ const {
116
+ className,
117
+ style,
118
+ onReady,
119
+ onOpen,
120
+ onClose,
121
+ onSave,
122
+ onError,
123
+ onDirtyChange,
124
+ ...config
125
+ } = props
126
+ const ref = useRef<SfxTemplateBuilder>(null)
127
+
128
+ // Hand the same element out to the caller without giving up the internal ref
129
+ // the effects below rely on.
130
+ useImperativeHandle(forwardedRef, () => ref.current as SfxTemplateBuilder, [])
131
+
132
+ // Assign config before paint so the iframe doesn't first mount with defaults.
133
+ useLayoutEffect(() => {
134
+ const el = ref.current
135
+ if (!el) return
136
+ el.baseUrl = config.baseUrl
137
+ el.token = config.token
138
+ el.sassKey = config.sassKey ?? ''
139
+ el.sessionUuid = config.sessionUuid ?? ''
140
+ el.secTemplate = config.secTemplate ?? ''
141
+ el.companyUuid = config.companyUuid ?? ''
142
+ el.projectUuid = config.projectUuid ?? ''
143
+ el.templateId = config.templateId ?? ''
144
+ el.mode = config.mode ?? 'inline'
145
+ el.stateless = config.stateless ?? false
146
+ el.templateName = config.templateName ?? ''
147
+ el.templateQuery = config.templateQuery ?? ''
148
+ el.brandColor = config.brandColor ?? ''
149
+ el.theme = config.theme ?? ''
150
+ // Assigned last: the element sends content to the app as soon as it has
151
+ // both a request and a value, so the id, name and query must already be
152
+ // set — all four ship as one message.
153
+ el.content = config.content ?? ''
154
+ if (config.readyTimeout !== undefined) el.readyTimeout = config.readyTimeout
155
+ }, [
156
+ config.baseUrl,
157
+ config.token,
158
+ config.sassKey,
159
+ config.sessionUuid,
160
+ config.secTemplate,
161
+ config.companyUuid,
162
+ config.projectUuid,
163
+ config.templateId,
164
+ config.mode,
165
+ config.stateless,
166
+ config.content,
167
+ config.templateName,
168
+ config.templateQuery,
169
+ config.brandColor,
170
+ config.theme,
171
+ config.readyTimeout,
172
+ ])
173
+
174
+ // Layout effect, not passive: the element reports config errors (e.g.
175
+ // `invalid-base-url`) in a microtask queued during this same commit, and a
176
+ // passive effect would subscribe only after that microtask has fired —
177
+ // making a mount-time error unobservable from React. This effect is declared
178
+ // after the config one, so it still runs once the config is assigned.
179
+ useLayoutEffect(() => {
180
+ const el = ref.current
181
+ if (!el) return
182
+ const subs: Array<[string, EventListener]> = []
183
+ const on = (name: string, handler?: (detail: never) => void) => {
184
+ if (!handler) return
185
+ const listener = ((e: CustomEvent) => handler(e.detail as never)) as EventListener
186
+ el.addEventListener(name, listener)
187
+ subs.push([name, listener])
188
+ }
189
+ on('ready', onReady)
190
+ on('open', onOpen)
191
+ on('close', onClose)
192
+ on('error', onError)
193
+ on('dirtychange', onDirtyChange)
194
+
195
+ // `save` is not just re-emitted: in stateless mode the handler's outcome
196
+ // is acked back, so a failed write on the host side doesn't leave the
197
+ // editor showing the template as saved. `confirmSave` no-ops in DAM mode.
198
+ if (onSave) {
199
+ const listener = ((e: CustomEvent<TemplateBuilderSaveDetail>) => {
200
+ // Wrapped in a promise so a synchronous throw is handled like a
201
+ // rejection, and a sync `false` like a resolved one.
202
+ Promise.resolve()
203
+ .then(() => onSave(e.detail))
204
+ .then((result) => el.confirmSave(result !== false))
205
+ .catch((err) => {
206
+ // No message: an internal error string is not something to put in
207
+ // front of the end user. The editor uses its own wording.
208
+ console.error('[sfx-template-builder] onSave failed:', err)
209
+ el.confirmSave(false)
210
+ })
211
+ }) as EventListener
212
+ el.addEventListener('save', listener)
213
+ subs.push(['save', listener])
214
+ }
215
+
216
+ return () => {
217
+ for (const [name, listener] of subs) el.removeEventListener(name, listener)
218
+ }
219
+ }, [onReady, onOpen, onClose, onSave, onError, onDirtyChange])
220
+
221
+ // eslint-disable-next-line react-hooks/refs -- ref is forwarded as a prop, not read during render
222
+ return createElement('sfx-template-builder', { ref, class: className, style })
223
+ })