@byline/admin 2.5.2 → 2.6.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.
Files changed (260) hide show
  1. package/dist/fields/array/array-field.d.ts +14 -0
  2. package/dist/fields/array/array-field.js +177 -0
  3. package/dist/fields/array/array-field.module.js +11 -0
  4. package/dist/fields/array/array-field_module.css +32 -0
  5. package/dist/fields/blocks/blocks-field.d.ts +13 -0
  6. package/dist/fields/blocks/blocks-field.js +245 -0
  7. package/dist/fields/blocks/blocks-field.module.js +26 -0
  8. package/dist/fields/blocks/blocks-field_module.css +107 -0
  9. package/dist/fields/checkbox/checkbox-field.d.ts +16 -0
  10. package/dist/fields/checkbox/checkbox-field.js +28 -0
  11. package/dist/fields/checkbox/checkbox-field.module.js +6 -0
  12. package/dist/fields/checkbox/checkbox-field_module.css +4 -0
  13. package/dist/fields/column-formatter.d.ts +20 -0
  14. package/dist/fields/column-formatter.js +15 -0
  15. package/dist/fields/date-time-formatter.d.ts +16 -0
  16. package/dist/fields/date-time-formatter.js +8 -0
  17. package/dist/fields/datetime/datetime-field.d.ts +16 -0
  18. package/dist/fields/datetime/datetime-field.js +37 -0
  19. package/dist/fields/datetime/datetime-field.module.js +5 -0
  20. package/dist/fields/datetime/datetime-field_module.css +4 -0
  21. package/dist/fields/draggable-context-menu.d.ts +6 -0
  22. package/dist/fields/draggable-context-menu.js +85 -0
  23. package/dist/fields/draggable-context-menu.module.js +15 -0
  24. package/dist/fields/draggable-context-menu_module.css +91 -0
  25. package/dist/fields/field-helpers.d.ts +26 -0
  26. package/dist/fields/field-helpers.js +50 -0
  27. package/dist/fields/field-renderer.d.ts +37 -0
  28. package/dist/fields/field-renderer.js +206 -0
  29. package/dist/fields/field-renderer.module.js +8 -0
  30. package/dist/fields/field-renderer_module.css +11 -0
  31. package/dist/fields/field-services-context.d.ts +16 -0
  32. package/dist/fields/field-services-context.js +13 -0
  33. package/dist/fields/field-services-types.d.ts +63 -0
  34. package/dist/fields/field-services-types.js +1 -0
  35. package/dist/fields/file/file-field.d.ts +19 -0
  36. package/dist/fields/file/file-field.js +225 -0
  37. package/dist/fields/file/file-field.module.js +18 -0
  38. package/dist/fields/file/file-field_module.css +131 -0
  39. package/dist/fields/file/file-upload-field.d.ts +21 -0
  40. package/dist/fields/file/file-upload-field.js +130 -0
  41. package/dist/fields/file/file-upload-field.module.js +15 -0
  42. package/dist/fields/file/file-upload-field_module.css +74 -0
  43. package/dist/fields/group/group-field.d.ts +15 -0
  44. package/dist/fields/group/group-field.js +59 -0
  45. package/dist/fields/group/group-field.module.js +9 -0
  46. package/dist/fields/group/group-field_module.css +27 -0
  47. package/dist/fields/image/image-field.d.ts +19 -0
  48. package/dist/fields/image/image-field.js +241 -0
  49. package/dist/fields/image/image-field.module.js +22 -0
  50. package/dist/fields/image/image-field_module.css +121 -0
  51. package/dist/fields/image/image-upload-field.d.ts +21 -0
  52. package/dist/fields/image/image-upload-field.js +190 -0
  53. package/dist/fields/image/image-upload-field.module.js +19 -0
  54. package/dist/fields/image/image-upload-field_module.css +92 -0
  55. package/dist/fields/local-date-time.d.ts +27 -0
  56. package/dist/fields/local-date-time.js +49 -0
  57. package/dist/fields/locale-badge.d.ts +18 -0
  58. package/dist/fields/locale-badge.js +10 -0
  59. package/dist/fields/locale-badge.module.js +5 -0
  60. package/dist/fields/locale-badge_module.css +27 -0
  61. package/dist/fields/numerical/numerical-field.d.ts +18 -0
  62. package/dist/fields/numerical/numerical-field.js +74 -0
  63. package/dist/fields/relation/relation-display.d.ts +40 -0
  64. package/dist/fields/relation/relation-display.js +61 -0
  65. package/dist/fields/relation/relation-display.module.js +9 -0
  66. package/dist/fields/relation/relation-display_module.css +21 -0
  67. package/dist/fields/relation/relation-field.d.ts +18 -0
  68. package/dist/fields/relation/relation-field.js +138 -0
  69. package/dist/fields/relation/relation-field.module.js +13 -0
  70. package/dist/fields/relation/relation-field_module.css +62 -0
  71. package/dist/fields/relation/relation-picker.d.ts +59 -0
  72. package/dist/fields/relation/relation-picker.js +237 -0
  73. package/dist/fields/relation/relation-picker.module.js +26 -0
  74. package/dist/fields/relation/relation-picker_module.css +124 -0
  75. package/dist/fields/relation/relation-summary.d.ts +31 -0
  76. package/dist/fields/relation/relation-summary.js +50 -0
  77. package/dist/fields/relation/relation-summary.module.js +11 -0
  78. package/dist/fields/relation/relation-summary_module.css +37 -0
  79. package/dist/fields/select/select-field.d.ts +16 -0
  80. package/dist/fields/select/select-field.js +50 -0
  81. package/dist/fields/select/select-field.module.js +5 -0
  82. package/dist/fields/select/select-field_module.css +4 -0
  83. package/dist/fields/sortable-item.d.ts +15 -0
  84. package/dist/fields/sortable-item.js +81 -0
  85. package/dist/fields/sortable-item.module.js +22 -0
  86. package/dist/fields/sortable-item_module.css +124 -0
  87. package/dist/fields/text/text-field.d.ts +20 -0
  88. package/dist/fields/text/text-field.js +104 -0
  89. package/dist/fields/text/text-field.module.js +6 -0
  90. package/dist/fields/text/text-field_module.css +5 -0
  91. package/dist/fields/text-area/text-area-field.d.ts +20 -0
  92. package/dist/fields/text-area/text-area-field.js +105 -0
  93. package/dist/fields/text-area/text-area-field.module.js +6 -0
  94. package/dist/fields/text-area/text-area-field_module.css +5 -0
  95. package/dist/fields/use-field-change-handler.d.ts +23 -0
  96. package/dist/fields/use-field-change-handler.js +52 -0
  97. package/dist/forms/document-actions.d.ts +48 -0
  98. package/dist/forms/document-actions.js +475 -0
  99. package/dist/forms/document-actions.module.js +34 -0
  100. package/dist/forms/document-actions_module.css +118 -0
  101. package/dist/forms/form-context.d.ts +89 -0
  102. package/dist/forms/form-context.js +466 -0
  103. package/dist/forms/form-renderer.d.ts +98 -0
  104. package/dist/forms/form-renderer.js +597 -0
  105. package/dist/forms/form-renderer.module.js +46 -0
  106. package/dist/forms/form-renderer_module.css +245 -0
  107. package/dist/forms/navigation-guard.d.ts +54 -0
  108. package/dist/forms/navigation-guard.js +22 -0
  109. package/dist/forms/path-widget.d.ts +36 -0
  110. package/dist/forms/path-widget.js +116 -0
  111. package/dist/forms/path-widget.module.js +8 -0
  112. package/dist/forms/path-widget_module.css +29 -0
  113. package/dist/forms/upload-executor.d.ts +57 -0
  114. package/dist/forms/upload-executor.js +94 -0
  115. package/dist/lib/translate-validation-error.d.ts +36 -0
  116. package/dist/lib/translate-validation-error.js +11 -0
  117. package/dist/modules/admin-account/commands.d.ts +2 -1
  118. package/dist/modules/admin-account/commands.js +13 -2
  119. package/dist/modules/admin-account/components/change-password.js +45 -36
  120. package/dist/modules/admin-account/components/container.js +185 -134
  121. package/dist/modules/admin-account/components/preferences.d.ts +8 -0
  122. package/dist/modules/admin-account/components/preferences.js +152 -0
  123. package/dist/modules/admin-account/components/preferences.module.js +11 -0
  124. package/dist/modules/admin-account/components/preferences_module.css +41 -0
  125. package/dist/modules/admin-account/components/update.js +50 -31
  126. package/dist/modules/admin-account/index.d.ts +3 -3
  127. package/dist/modules/admin-account/index.js +2 -2
  128. package/dist/modules/admin-account/schemas.d.ts +4 -0
  129. package/dist/modules/admin-account/schemas.js +4 -1
  130. package/dist/modules/admin-account/service.d.ts +1 -0
  131. package/dist/modules/admin-account/service.js +8 -0
  132. package/dist/modules/admin-permissions/components/inspector.js +31 -41
  133. package/dist/modules/admin-roles/components/create.js +43 -26
  134. package/dist/modules/admin-roles/components/permissions.js +26 -35
  135. package/dist/modules/admin-roles/components/update.js +26 -16
  136. package/dist/modules/admin-users/components/create.js +60 -40
  137. package/dist/modules/admin-users/components/roles.js +9 -15
  138. package/dist/modules/admin-users/components/set-password.js +30 -31
  139. package/dist/modules/admin-users/components/update.js +58 -39
  140. package/dist/modules/admin-users/dto.js +1 -0
  141. package/dist/modules/admin-users/repository.d.ts +17 -0
  142. package/dist/modules/admin-users/schemas.d.ts +4 -0
  143. package/dist/modules/admin-users/schemas.js +6 -2
  144. package/dist/modules/auth/components/sign-in-form.js +10 -8
  145. package/dist/presentation/group.d.ts +27 -0
  146. package/dist/presentation/group.js +14 -0
  147. package/dist/presentation/group.module.js +6 -0
  148. package/dist/presentation/group_module.css +19 -0
  149. package/dist/presentation/row.d.ts +25 -0
  150. package/dist/presentation/row.js +8 -0
  151. package/dist/presentation/row.module.js +5 -0
  152. package/dist/presentation/row_module.css +18 -0
  153. package/dist/presentation/tabs.d.ts +25 -0
  154. package/dist/presentation/tabs.js +39 -0
  155. package/dist/presentation/tabs.module.js +10 -0
  156. package/dist/presentation/tabs_module.css +68 -0
  157. package/dist/react.d.ts +66 -0
  158. package/dist/react.js +36 -0
  159. package/dist/services/admin-services-types.d.ts +16 -0
  160. package/dist/widgets/diff-viewer/diff-modal.d.ts +22 -0
  161. package/dist/widgets/diff-viewer/diff-modal.js +149 -0
  162. package/dist/widgets/diff-viewer/diff-modal.module.js +14 -0
  163. package/dist/widgets/diff-viewer/diff-modal_module.css +56 -0
  164. package/dist/widgets/status-badge/status-badge.d.ts +25 -0
  165. package/dist/widgets/status-badge/status-badge.js +37 -0
  166. package/dist/widgets/status-badge/status-badge.module.js +7 -0
  167. package/dist/widgets/status-badge/status-badge_module.css +20 -0
  168. package/package.json +14 -4
  169. package/src/fields/array/array-field.module.css +48 -0
  170. package/src/fields/array/array-field.tsx +267 -0
  171. package/src/fields/blocks/blocks-field.module.css +148 -0
  172. package/src/fields/blocks/blocks-field.tsx +323 -0
  173. package/src/fields/checkbox/checkbox-field.module.css +4 -0
  174. package/src/fields/checkbox/checkbox-field.tsx +54 -0
  175. package/src/fields/column-formatter.tsx +31 -0
  176. package/src/fields/date-time-formatter.tsx +22 -0
  177. package/src/fields/datetime/datetime-field.module.css +13 -0
  178. package/src/fields/datetime/datetime-field.tsx +54 -0
  179. package/src/fields/draggable-context-menu.module.css +127 -0
  180. package/src/fields/draggable-context-menu.tsx +87 -0
  181. package/src/fields/field-helpers.ts +69 -0
  182. package/src/fields/field-renderer.module.css +22 -0
  183. package/src/fields/field-renderer.tsx +288 -0
  184. package/src/fields/field-services-context.tsx +35 -0
  185. package/src/fields/field-services-types.ts +68 -0
  186. package/src/fields/file/file-field.module.css +153 -0
  187. package/src/fields/file/file-field.tsx +286 -0
  188. package/src/fields/file/file-upload-field.module.css +101 -0
  189. package/src/fields/file/file-upload-field.tsx +187 -0
  190. package/src/fields/group/group-field.module.css +43 -0
  191. package/src/fields/group/group-field.tsx +84 -0
  192. package/src/fields/image/image-field.module.css +155 -0
  193. package/src/fields/image/image-field.tsx +306 -0
  194. package/src/fields/image/image-upload-field.module.css +123 -0
  195. package/src/fields/image/image-upload-field.tsx +276 -0
  196. package/src/fields/local-date-time.tsx +88 -0
  197. package/src/fields/locale-badge.module.css +37 -0
  198. package/src/fields/locale-badge.tsx +32 -0
  199. package/src/fields/numerical/numerical-field.tsx +114 -0
  200. package/src/fields/relation/relation-display.module.css +36 -0
  201. package/src/fields/relation/relation-display.tsx +138 -0
  202. package/src/fields/relation/relation-field.module.css +83 -0
  203. package/src/fields/relation/relation-field.tsx +211 -0
  204. package/src/fields/relation/relation-picker.module.css +168 -0
  205. package/src/fields/relation/relation-picker.tsx +343 -0
  206. package/src/fields/relation/relation-summary.module.css +55 -0
  207. package/src/fields/relation/relation-summary.tsx +123 -0
  208. package/src/fields/select/select-field.module.css +13 -0
  209. package/src/fields/select/select-field.tsx +61 -0
  210. package/src/fields/sortable-item.module.css +167 -0
  211. package/src/fields/sortable-item.tsx +106 -0
  212. package/src/fields/text/text-field.module.css +13 -0
  213. package/src/fields/text/text-field.tsx +146 -0
  214. package/src/fields/text-area/text-area-field.module.css +13 -0
  215. package/src/fields/text-area/text-area-field.tsx +147 -0
  216. package/src/fields/use-field-change-handler.ts +112 -0
  217. package/src/forms/document-actions.module.css +160 -0
  218. package/src/forms/document-actions.tsx +482 -0
  219. package/src/forms/form-context.tsx +704 -0
  220. package/src/forms/form-renderer.module.css +321 -0
  221. package/src/forms/form-renderer.tsx +891 -0
  222. package/src/forms/navigation-guard.tsx +98 -0
  223. package/src/forms/path-widget.module.css +41 -0
  224. package/src/forms/path-widget.test.tsx +217 -0
  225. package/src/forms/path-widget.tsx +183 -0
  226. package/src/forms/upload-executor.ts +192 -0
  227. package/src/lib/translate-validation-error.ts +56 -0
  228. package/src/modules/admin-account/commands.ts +13 -0
  229. package/src/modules/admin-account/components/change-password.tsx +46 -31
  230. package/src/modules/admin-account/components/container.tsx +83 -38
  231. package/src/modules/admin-account/components/preferences.module.css +60 -0
  232. package/src/modules/admin-account/components/preferences.tsx +203 -0
  233. package/src/modules/admin-account/components/update.tsx +53 -27
  234. package/src/modules/admin-account/index.ts +3 -0
  235. package/src/modules/admin-account/schemas.ts +13 -0
  236. package/src/modules/admin-account/service.ts +12 -0
  237. package/src/modules/admin-permissions/components/inspector.tsx +22 -14
  238. package/src/modules/admin-roles/components/create.tsx +51 -23
  239. package/src/modules/admin-roles/components/permissions.tsx +25 -21
  240. package/src/modules/admin-roles/components/update.tsx +37 -19
  241. package/src/modules/admin-users/components/create.tsx +63 -34
  242. package/src/modules/admin-users/components/roles.tsx +9 -8
  243. package/src/modules/admin-users/components/set-password.tsx +34 -28
  244. package/src/modules/admin-users/components/update.tsx +58 -36
  245. package/src/modules/admin-users/dto.ts +1 -0
  246. package/src/modules/admin-users/repository.ts +17 -0
  247. package/src/modules/admin-users/schemas.ts +12 -0
  248. package/src/modules/auth/components/sign-in-form.tsx +14 -8
  249. package/src/presentation/group.module.css +41 -0
  250. package/src/presentation/group.tsx +40 -0
  251. package/src/presentation/row.module.css +32 -0
  252. package/src/presentation/row.tsx +33 -0
  253. package/src/presentation/tabs.module.css +107 -0
  254. package/src/presentation/tabs.tsx +84 -0
  255. package/src/react.ts +84 -0
  256. package/src/services/admin-services-types.ts +18 -0
  257. package/src/widgets/diff-viewer/diff-modal.module.css +79 -0
  258. package/src/widgets/diff-viewer/diff-modal.tsx +186 -0
  259. package/src/widgets/status-badge/status-badge.module.css +31 -0
  260. package/src/widgets/status-badge/status-badge.tsx +71 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Framework-agnostic navigation guard adapter.
11
+ *
12
+ * Different router frameworks (TanStack Router, Next.js, React Router, etc.)
13
+ * each have their own mechanism for blocking navigation when a form has unsaved
14
+ * changes. This module defines a common interface so that `FormRenderer` can
15
+ * remain framework-independent — the consuming application injects the
16
+ * appropriate adapter via a prop or React context.
17
+ */
18
+
19
+ import { createContext, useContext, useEffect } from 'react'
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Types
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** The result returned by a `UseNavigationGuard` hook. */
26
+ export interface NavigationGuardResult {
27
+ /** Whether a navigation attempt is currently being blocked (show confirmation UI). */
28
+ isBlocked: boolean
29
+ /** Cancel the pending navigation — stay on the current page. */
30
+ stay: () => void
31
+ /** Confirm the pending navigation — leave the page. */
32
+ proceed: () => void
33
+ }
34
+
35
+ /**
36
+ * A hook that blocks in-app navigation and (optionally) browser unload when
37
+ * `shouldBlock` is `true`.
38
+ *
39
+ * Each framework adapter implements this signature.
40
+ */
41
+ export type UseNavigationGuard = (shouldBlock: boolean) => NavigationGuardResult
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Default (no-op) implementation — browser `beforeunload` only
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Fallback navigation guard that only handles the browser's native
49
+ * `beforeunload` event. In-app (client-side) route changes are **not**
50
+ * intercepted — `isBlocked` will never become `true`.
51
+ *
52
+ * This is used when no framework-specific adapter has been provided.
53
+ */
54
+ export const useBeforeUnloadGuard: UseNavigationGuard = (shouldBlock) => {
55
+ useEffect(() => {
56
+ if (!shouldBlock) return
57
+ const handler = (e: BeforeUnloadEvent) => {
58
+ e.preventDefault()
59
+ }
60
+ window.addEventListener('beforeunload', handler)
61
+ return () => window.removeEventListener('beforeunload', handler)
62
+ }, [shouldBlock])
63
+
64
+ return { isBlocked: false, stay: () => {}, proceed: () => {} }
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // React context — allows setting the adapter once at the app shell level
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const NavigationGuardContext = createContext<UseNavigationGuard>(useBeforeUnloadGuard)
72
+
73
+ /**
74
+ * Provide a framework-specific `UseNavigationGuard` hook to all descendant
75
+ * `FormRenderer` instances.
76
+ *
77
+ * ```tsx
78
+ * import { NavigationGuardProvider } from './navigation-guard'
79
+ * import { useTanStackNavigationGuard } from './tanstack-navigation-guard'
80
+ *
81
+ * function App() {
82
+ * return (
83
+ * <NavigationGuardProvider value={useTanStackNavigationGuard}>
84
+ * <Outlet />
85
+ * </NavigationGuardProvider>
86
+ * )
87
+ * }
88
+ * ```
89
+ */
90
+ export const NavigationGuardProvider = NavigationGuardContext.Provider
91
+
92
+ /**
93
+ * Consume the current `UseNavigationGuard` hook from context.
94
+ * Falls back to `useBeforeUnloadGuard` when no provider is present.
95
+ */
96
+ export const useNavigationGuardAdapter = (): UseNavigationGuard => {
97
+ return useContext(NavigationGuardContext)
98
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * PathWidget — system-managed `documentVersions.path` form widget.
3
+ *
4
+ * Override handles:
5
+ * .byline-form-path — wrapper div
6
+ * .byline-form-path-header — label + regenerate-button row
7
+ * .byline-form-path-regenerate — regenerate-link button
8
+ * .byline-form-path-sr-only — visually-hidden screen-reader hint
9
+ */
10
+
11
+ .header,
12
+ :global(.byline-form-path-header) {
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: space-between;
16
+ gap: var(--spacing-8);
17
+ }
18
+
19
+ .regenerate,
20
+ :global(.byline-form-path-regenerate) {
21
+ background: none;
22
+ border: none;
23
+ padding: 0;
24
+ color: inherit;
25
+ font-size: 0.8rem;
26
+ text-decoration: underline;
27
+ cursor: pointer;
28
+ }
29
+
30
+ .sr-only,
31
+ :global(.byline-form-path-sr-only) {
32
+ position: absolute;
33
+ width: 1px;
34
+ height: 1px;
35
+ padding: 0;
36
+ margin: -1px;
37
+ overflow: hidden;
38
+ clip: rect(0, 0, 0, 0);
39
+ white-space: nowrap;
40
+ border: 0;
41
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { act } from 'react'
10
+
11
+ import { createRoot, type Root } from 'react-dom/client'
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13
+
14
+ // Lightweight uikit stubs — we don't care about the visual rendering, only
15
+ // that the Input forwards props and the Label renders its htmlFor.
16
+ vi.mock('@byline/ui/react', () => ({
17
+ Label: ({ id, htmlFor, label }: { id?: string; htmlFor?: string; label?: string }) => (
18
+ <label id={id} htmlFor={htmlFor}>
19
+ {label}
20
+ </label>
21
+ ),
22
+ Input: ({
23
+ id,
24
+ name,
25
+ value,
26
+ placeholder,
27
+ onChange,
28
+ helpText,
29
+ ...rest
30
+ }: {
31
+ id?: string
32
+ name?: string
33
+ value?: string
34
+ placeholder?: string
35
+ onChange?: (e: { target: { value: string } }) => void
36
+ helpText?: string
37
+ [key: string]: any
38
+ }) => (
39
+ <>
40
+ <input
41
+ id={id}
42
+ name={name}
43
+ value={value ?? ''}
44
+ placeholder={placeholder}
45
+ onChange={onChange}
46
+ {...rest}
47
+ />
48
+ {helpText ? <span data-testid="help-text">{helpText}</span> : null}
49
+ </>
50
+ ),
51
+ }))
52
+
53
+ // Mutable mocks controlled per-test via the setFixture helper below.
54
+ const fixture: {
55
+ systemPath: string | null
56
+ sourceValue: unknown
57
+ setSystemPath: ReturnType<typeof vi.fn>
58
+ } = {
59
+ systemPath: null,
60
+ sourceValue: '',
61
+ setSystemPath: vi.fn(),
62
+ }
63
+
64
+ vi.mock('./form-context', () => ({
65
+ useFormContext: () => ({ setSystemPath: fixture.setSystemPath }),
66
+ useSystemPath: () => fixture.systemPath,
67
+ useFieldValue: () => fixture.sourceValue,
68
+ }))
69
+
70
+ // Import AFTER the mocks so PathWidget picks them up.
71
+ // biome-ignore lint/correctness/useImportExtensions: webapp TS resolves via tsconfig paths
72
+ import { PathWidget } from './path-widget'
73
+
74
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
75
+
76
+ interface Fixture {
77
+ systemPath?: string | null
78
+ sourceValue?: unknown
79
+ }
80
+
81
+ function setFixture(next: Fixture = {}) {
82
+ fixture.systemPath = next.systemPath ?? null
83
+ fixture.sourceValue = next.sourceValue ?? ''
84
+ fixture.setSystemPath = vi.fn()
85
+ }
86
+
87
+ describe('PathWidget', () => {
88
+ let container: HTMLDivElement
89
+ let root: Root
90
+
91
+ beforeEach(() => {
92
+ container = document.createElement('div')
93
+ document.body.appendChild(container)
94
+ root = createRoot(container)
95
+ })
96
+
97
+ afterEach(() => {
98
+ act(() => {
99
+ root.unmount()
100
+ })
101
+ container.remove()
102
+ })
103
+
104
+ const render = (
105
+ props: Partial<{
106
+ useAsPath: string | undefined
107
+ mode: 'create' | 'edit'
108
+ }> = {}
109
+ ) => {
110
+ act(() => {
111
+ root.render(
112
+ <PathWidget
113
+ useAsPath={props.useAsPath ?? 'title'}
114
+ collectionPath="pages"
115
+ defaultLocale="en"
116
+ mode={props.mode ?? 'create'}
117
+ />
118
+ )
119
+ })
120
+ }
121
+
122
+ const getInput = () => container.querySelector('#system-path') as HTMLInputElement
123
+
124
+ it('shows the live-derived preview as placeholder when creating with an empty override', () => {
125
+ setFixture({ systemPath: null, sourceValue: 'Hello World' })
126
+ render({ mode: 'create' })
127
+
128
+ const input = getInput()
129
+ expect(input).toBeTruthy()
130
+ expect(input.value).toBe('')
131
+ expect(input.getAttribute('placeholder')).toBe('Will be saved as "hello-world"')
132
+ })
133
+
134
+ it('shows the persisted path in edit mode (no placeholder preview)', () => {
135
+ setFixture({ systemPath: 'existing-path', sourceValue: 'Hello World' })
136
+ render({ mode: 'edit' })
137
+
138
+ const input = getInput()
139
+ expect(input.value).toBe('existing-path')
140
+ // livePreview === 'hello-world' differs from persisted 'existing-path',
141
+ // so the regenerate button is rendered.
142
+ const regenerate = container.querySelector('button')
143
+ expect(regenerate).toBeTruthy()
144
+ expect(regenerate?.textContent).toContain('Regenerate from title')
145
+ })
146
+
147
+ it('"Regenerate" writes the live preview into the systemPath slot', () => {
148
+ setFixture({ systemPath: 'stale-path', sourceValue: 'Brand New Title' })
149
+ render({ mode: 'edit' })
150
+
151
+ const regenerate = container.querySelector('button') as HTMLButtonElement
152
+ expect(regenerate).toBeTruthy()
153
+
154
+ act(() => {
155
+ regenerate.click()
156
+ })
157
+
158
+ expect(fixture.setSystemPath).toHaveBeenCalledWith('brand-new-title')
159
+ })
160
+
161
+ it('clearing the input reverts the slot to null (sticky-from-previous)', () => {
162
+ setFixture({ systemPath: 'my-path', sourceValue: 'My Path' })
163
+ render({ mode: 'edit' })
164
+
165
+ const input = getInput()
166
+ act(() => {
167
+ const setter = Object.getOwnPropertyDescriptor(
168
+ window.HTMLInputElement.prototype,
169
+ 'value'
170
+ )?.set
171
+ setter?.call(input, '')
172
+ input.dispatchEvent(new Event('input', { bubbles: true }))
173
+ })
174
+
175
+ expect(fixture.setSystemPath).toHaveBeenCalledWith(null)
176
+ })
177
+
178
+ it('typing a non-empty value writes a string override', () => {
179
+ setFixture({ systemPath: null, sourceValue: 'Anything' })
180
+ render({ mode: 'create' })
181
+
182
+ const input = getInput()
183
+ act(() => {
184
+ const setter = Object.getOwnPropertyDescriptor(
185
+ window.HTMLInputElement.prototype,
186
+ 'value'
187
+ )?.set
188
+ setter?.call(input, 'custom-slug')
189
+ input.dispatchEvent(new Event('input', { bubbles: true }))
190
+ })
191
+
192
+ expect(fixture.setSystemPath).toHaveBeenCalledWith('custom-slug')
193
+ })
194
+
195
+ it('links the input to an sr-only description via aria-describedby', () => {
196
+ setFixture({ systemPath: null, sourceValue: 'Hi' })
197
+ render({ mode: 'create' })
198
+
199
+ const input = getInput()
200
+ expect(input.getAttribute('aria-describedby')).toBe('system-path-description')
201
+ const description = container.querySelector('#system-path-description')
202
+ expect(description).toBeTruthy()
203
+ expect(description?.textContent).toContain('System-managed URL path')
204
+ })
205
+
206
+ it('does not render the Regenerate button when livePreview equals systemPath', () => {
207
+ setFixture({ systemPath: 'hello-world', sourceValue: 'Hello World' })
208
+ render({ mode: 'edit' })
209
+ expect(container.querySelector('button')).toBeNull()
210
+ })
211
+
212
+ it('does not render the Regenerate button when there is no useAsPath', () => {
213
+ setFixture({ systemPath: 'whatever', sourceValue: '' })
214
+ render({ mode: 'edit', useAsPath: undefined })
215
+ expect(container.querySelector('button')).toBeNull()
216
+ })
217
+ })
@@ -0,0 +1,183 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useCallback, useMemo } from 'react'
12
+
13
+ import { slugify } from '@byline/core'
14
+ import { useTranslation } from '@byline/i18n/react'
15
+ import { Input, Label } from '@byline/ui/react'
16
+ import cx from 'classnames'
17
+
18
+ import { useFieldValue, useFormContext, useSystemPath } from './form-context'
19
+ import styles from './path-widget.module.css'
20
+
21
+ /**
22
+ * Coerce an arbitrary source-field value (string, Date, or other) into
23
+ * a string suitable for slugification. Mirrors the lifecycle's coercion
24
+ * so the live preview matches what the server will store.
25
+ */
26
+ function coerceToString(value: unknown): string {
27
+ if (value == null) return ''
28
+ if (value instanceof Date) return value.toISOString()
29
+ return String(value)
30
+ }
31
+
32
+ export interface PathWidgetProps {
33
+ /** The collection's `useAsPath` source field name, when configured. */
34
+ useAsPath: string | undefined
35
+ /** Collection path, forwarded to the slugifier as context. */
36
+ collectionPath: string
37
+ /** Default content locale, forwarded to the slugifier as context. */
38
+ defaultLocale: string
39
+ /**
40
+ * The locale currently being edited in the form. When this differs
41
+ * from `defaultLocale` (i.e. the editor is editing a translation),
42
+ * the widget renders read-only — phase 1 paths are default-locale
43
+ * territory, and the lifecycle drops translation-locale path changes
44
+ * with a warn. Locking the input prevents the warn path being hit
45
+ * through the admin form and gives editors a clear cue.
46
+ */
47
+ activeLocale: string
48
+ /** `'create'` shows the live derived preview as placeholder text. */
49
+ mode: 'create' | 'edit'
50
+ }
51
+
52
+ /**
53
+ * System-managed `path` widget.
54
+ *
55
+ * Edits the path stored in `byline_document_paths` for the current
56
+ * (document, locale) row. Displays the current persisted/overridden
57
+ * value as an editable input.
58
+ * In create mode, when the user hasn't supplied an override, the input
59
+ * shows the live-derived preview (slugified `useAsPath` source field) as
60
+ * a placeholder so the user sees what will be saved. The "Regenerate"
61
+ * action explicitly writes the current live preview into the override
62
+ * slot so the user can re-anchor a path against the source field after
63
+ * editing the title.
64
+ *
65
+ * Stable override handles: `.byline-form-path`, `.byline-form-path-header`,
66
+ * `.byline-form-path-regenerate`.
67
+ */
68
+ export const PathWidget = ({
69
+ useAsPath,
70
+ collectionPath,
71
+ defaultLocale,
72
+ activeLocale,
73
+ mode,
74
+ }: PathWidgetProps) => {
75
+ const { setSystemPath } = useFormContext()
76
+ const { t } = useTranslation('byline-admin')
77
+ const systemPath = useSystemPath()
78
+ const sourceValue = useFieldValue<unknown>(useAsPath ?? '')
79
+
80
+ // Phase 1: paths are written/edited only under the default content
81
+ // locale. When editing a translation, the widget locks down — the
82
+ // input is read-only, the Regenerate action is suppressed, and a
83
+ // helpText line explains why.
84
+ const isReadOnly = activeLocale !== defaultLocale
85
+
86
+ // Live preview — what the server would derive from the current source
87
+ // field value if no override were set. Used as placeholder in create
88
+ // mode and as the target of the "Regenerate" action.
89
+ const livePreview = useMemo(() => {
90
+ if (!useAsPath) return ''
91
+ const asString = coerceToString(sourceValue)
92
+ if (asString.length === 0) return ''
93
+ return slugify(asString, { locale: defaultLocale, collectionPath })
94
+ }, [useAsPath, sourceValue, defaultLocale, collectionPath])
95
+
96
+ const inputValue = systemPath ?? ''
97
+
98
+ const handleChange = useCallback(
99
+ (next: string) => {
100
+ // Empty string clears the override — server falls back to derive
101
+ // (create) or sticky (update).
102
+ setSystemPath(next.length === 0 ? null : next)
103
+ },
104
+ [setSystemPath]
105
+ )
106
+
107
+ const handleRegenerate = useCallback(() => {
108
+ if (livePreview.length > 0) {
109
+ setSystemPath(livePreview)
110
+ }
111
+ }, [livePreview, setSystemPath])
112
+
113
+ // Validate live: if the typed value differs from its slugified form,
114
+ // surface an inline hint without blocking input (mirrors the previous
115
+ // field-hook advisory behaviour).
116
+ const formatted = useMemo(() => {
117
+ if (inputValue.length === 0) return ''
118
+ return slugify(inputValue, { locale: defaultLocale, collectionPath })
119
+ }, [inputValue, defaultLocale, collectionPath])
120
+
121
+ const validationHint =
122
+ inputValue.length > 0 && formatted !== inputValue
123
+ ? t('pathWidget.suggestedHint', { formatted })
124
+ : undefined
125
+
126
+ // When read-only, replace the live validation hint with a fixed
127
+ // explanatory line so editors understand why the field is locked.
128
+ const readOnlyHint = isReadOnly
129
+ ? t('pathWidget.readOnlyHint', { locale: defaultLocale })
130
+ : undefined
131
+
132
+ const hint = readOnlyHint ?? validationHint
133
+
134
+ const placeholder =
135
+ !isReadOnly && mode === 'create' && livePreview.length > 0
136
+ ? t('pathWidget.willBeSavedAs', { preview: livePreview })
137
+ : undefined
138
+
139
+ // Screen-reader description. The input's base purpose ("System-managed
140
+ // URL path") plus whichever of the visible hints (placeholder preview
141
+ // in create mode, "Suggested" validation hint, or read-only explainer)
142
+ // currently applies. The visible helpText/placeholder cover sighted
143
+ // users; this element makes the same information addressable via
144
+ // aria-describedby for AT.
145
+ const srDescription = [t('pathWidget.srDescription'), placeholder, hint].filter(Boolean).join(' ')
146
+
147
+ const showRegenerate =
148
+ !isReadOnly && useAsPath && livePreview.length > 0 && livePreview !== systemPath
149
+
150
+ return (
151
+ <div className="byline-form-path">
152
+ <div className={cx('byline-form-path-header', styles.header)}>
153
+ <Label id="system-path-label" htmlFor="system-path" label={t('pathWidget.label')} />
154
+ {showRegenerate && (
155
+ <button
156
+ type="button"
157
+ onClick={handleRegenerate}
158
+ className={cx('byline-form-path-regenerate', styles.regenerate)}
159
+ aria-label={t('pathWidget.regenerateAriaLabel', { field: useAsPath })}
160
+ >
161
+ {t('pathWidget.regenerateButton', { field: useAsPath })}
162
+ </button>
163
+ )}
164
+ </div>
165
+ <Input
166
+ id="system-path"
167
+ name="__systemPath__"
168
+ value={inputValue}
169
+ placeholder={placeholder}
170
+ onChange={(e) => handleChange(e.target.value)}
171
+ helpText={hint}
172
+ readOnly={isReadOnly}
173
+ aria-describedby="system-path-description"
174
+ />
175
+ <span
176
+ id="system-path-description"
177
+ className={cx('byline-form-path-sr-only', styles['sr-only'])}
178
+ >
179
+ {srDescription}
180
+ </span>
181
+ </div>
182
+ )
183
+ }