@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.
- package/dist/fields/array/array-field.d.ts +14 -0
- package/dist/fields/array/array-field.js +177 -0
- package/dist/fields/array/array-field.module.js +11 -0
- package/dist/fields/array/array-field_module.css +32 -0
- package/dist/fields/blocks/blocks-field.d.ts +13 -0
- package/dist/fields/blocks/blocks-field.js +245 -0
- package/dist/fields/blocks/blocks-field.module.js +26 -0
- package/dist/fields/blocks/blocks-field_module.css +107 -0
- package/dist/fields/checkbox/checkbox-field.d.ts +16 -0
- package/dist/fields/checkbox/checkbox-field.js +28 -0
- package/dist/fields/checkbox/checkbox-field.module.js +6 -0
- package/dist/fields/checkbox/checkbox-field_module.css +4 -0
- package/dist/fields/column-formatter.d.ts +20 -0
- package/dist/fields/column-formatter.js +15 -0
- package/dist/fields/date-time-formatter.d.ts +16 -0
- package/dist/fields/date-time-formatter.js +8 -0
- package/dist/fields/datetime/datetime-field.d.ts +16 -0
- package/dist/fields/datetime/datetime-field.js +37 -0
- package/dist/fields/datetime/datetime-field.module.js +5 -0
- package/dist/fields/datetime/datetime-field_module.css +4 -0
- package/dist/fields/draggable-context-menu.d.ts +6 -0
- package/dist/fields/draggable-context-menu.js +85 -0
- package/dist/fields/draggable-context-menu.module.js +15 -0
- package/dist/fields/draggable-context-menu_module.css +91 -0
- package/dist/fields/field-helpers.d.ts +26 -0
- package/dist/fields/field-helpers.js +50 -0
- package/dist/fields/field-renderer.d.ts +37 -0
- package/dist/fields/field-renderer.js +206 -0
- package/dist/fields/field-renderer.module.js +8 -0
- package/dist/fields/field-renderer_module.css +11 -0
- package/dist/fields/field-services-context.d.ts +16 -0
- package/dist/fields/field-services-context.js +13 -0
- package/dist/fields/field-services-types.d.ts +63 -0
- package/dist/fields/field-services-types.js +1 -0
- package/dist/fields/file/file-field.d.ts +19 -0
- package/dist/fields/file/file-field.js +225 -0
- package/dist/fields/file/file-field.module.js +18 -0
- package/dist/fields/file/file-field_module.css +131 -0
- package/dist/fields/file/file-upload-field.d.ts +21 -0
- package/dist/fields/file/file-upload-field.js +130 -0
- package/dist/fields/file/file-upload-field.module.js +15 -0
- package/dist/fields/file/file-upload-field_module.css +74 -0
- package/dist/fields/group/group-field.d.ts +15 -0
- package/dist/fields/group/group-field.js +59 -0
- package/dist/fields/group/group-field.module.js +9 -0
- package/dist/fields/group/group-field_module.css +27 -0
- package/dist/fields/image/image-field.d.ts +19 -0
- package/dist/fields/image/image-field.js +241 -0
- package/dist/fields/image/image-field.module.js +22 -0
- package/dist/fields/image/image-field_module.css +121 -0
- package/dist/fields/image/image-upload-field.d.ts +21 -0
- package/dist/fields/image/image-upload-field.js +190 -0
- package/dist/fields/image/image-upload-field.module.js +19 -0
- package/dist/fields/image/image-upload-field_module.css +92 -0
- package/dist/fields/local-date-time.d.ts +27 -0
- package/dist/fields/local-date-time.js +49 -0
- package/dist/fields/locale-badge.d.ts +18 -0
- package/dist/fields/locale-badge.js +10 -0
- package/dist/fields/locale-badge.module.js +5 -0
- package/dist/fields/locale-badge_module.css +27 -0
- package/dist/fields/numerical/numerical-field.d.ts +18 -0
- package/dist/fields/numerical/numerical-field.js +74 -0
- package/dist/fields/relation/relation-display.d.ts +40 -0
- package/dist/fields/relation/relation-display.js +61 -0
- package/dist/fields/relation/relation-display.module.js +9 -0
- package/dist/fields/relation/relation-display_module.css +21 -0
- package/dist/fields/relation/relation-field.d.ts +18 -0
- package/dist/fields/relation/relation-field.js +138 -0
- package/dist/fields/relation/relation-field.module.js +13 -0
- package/dist/fields/relation/relation-field_module.css +62 -0
- package/dist/fields/relation/relation-picker.d.ts +59 -0
- package/dist/fields/relation/relation-picker.js +237 -0
- package/dist/fields/relation/relation-picker.module.js +26 -0
- package/dist/fields/relation/relation-picker_module.css +124 -0
- package/dist/fields/relation/relation-summary.d.ts +31 -0
- package/dist/fields/relation/relation-summary.js +50 -0
- package/dist/fields/relation/relation-summary.module.js +11 -0
- package/dist/fields/relation/relation-summary_module.css +37 -0
- package/dist/fields/select/select-field.d.ts +16 -0
- package/dist/fields/select/select-field.js +50 -0
- package/dist/fields/select/select-field.module.js +5 -0
- package/dist/fields/select/select-field_module.css +4 -0
- package/dist/fields/sortable-item.d.ts +15 -0
- package/dist/fields/sortable-item.js +81 -0
- package/dist/fields/sortable-item.module.js +22 -0
- package/dist/fields/sortable-item_module.css +124 -0
- package/dist/fields/text/text-field.d.ts +20 -0
- package/dist/fields/text/text-field.js +104 -0
- package/dist/fields/text/text-field.module.js +6 -0
- package/dist/fields/text/text-field_module.css +5 -0
- package/dist/fields/text-area/text-area-field.d.ts +20 -0
- package/dist/fields/text-area/text-area-field.js +105 -0
- package/dist/fields/text-area/text-area-field.module.js +6 -0
- package/dist/fields/text-area/text-area-field_module.css +5 -0
- package/dist/fields/use-field-change-handler.d.ts +23 -0
- package/dist/fields/use-field-change-handler.js +52 -0
- package/dist/forms/document-actions.d.ts +48 -0
- package/dist/forms/document-actions.js +475 -0
- package/dist/forms/document-actions.module.js +34 -0
- package/dist/forms/document-actions_module.css +118 -0
- package/dist/forms/form-context.d.ts +89 -0
- package/dist/forms/form-context.js +466 -0
- package/dist/forms/form-renderer.d.ts +98 -0
- package/dist/forms/form-renderer.js +597 -0
- package/dist/forms/form-renderer.module.js +46 -0
- package/dist/forms/form-renderer_module.css +245 -0
- package/dist/forms/navigation-guard.d.ts +54 -0
- package/dist/forms/navigation-guard.js +22 -0
- package/dist/forms/path-widget.d.ts +36 -0
- package/dist/forms/path-widget.js +116 -0
- package/dist/forms/path-widget.module.js +8 -0
- package/dist/forms/path-widget_module.css +29 -0
- package/dist/forms/upload-executor.d.ts +57 -0
- package/dist/forms/upload-executor.js +94 -0
- package/dist/lib/translate-validation-error.d.ts +36 -0
- package/dist/lib/translate-validation-error.js +11 -0
- package/dist/modules/admin-account/commands.d.ts +2 -1
- package/dist/modules/admin-account/commands.js +13 -2
- package/dist/modules/admin-account/components/change-password.js +45 -36
- package/dist/modules/admin-account/components/container.js +185 -134
- package/dist/modules/admin-account/components/preferences.d.ts +8 -0
- package/dist/modules/admin-account/components/preferences.js +152 -0
- package/dist/modules/admin-account/components/preferences.module.js +11 -0
- package/dist/modules/admin-account/components/preferences_module.css +41 -0
- package/dist/modules/admin-account/components/update.js +50 -31
- package/dist/modules/admin-account/index.d.ts +3 -3
- package/dist/modules/admin-account/index.js +2 -2
- package/dist/modules/admin-account/schemas.d.ts +4 -0
- package/dist/modules/admin-account/schemas.js +4 -1
- package/dist/modules/admin-account/service.d.ts +1 -0
- package/dist/modules/admin-account/service.js +8 -0
- package/dist/modules/admin-permissions/components/inspector.js +31 -41
- package/dist/modules/admin-roles/components/create.js +43 -26
- package/dist/modules/admin-roles/components/permissions.js +26 -35
- package/dist/modules/admin-roles/components/update.js +26 -16
- package/dist/modules/admin-users/components/create.js +60 -40
- package/dist/modules/admin-users/components/roles.js +9 -15
- package/dist/modules/admin-users/components/set-password.js +30 -31
- package/dist/modules/admin-users/components/update.js +58 -39
- package/dist/modules/admin-users/dto.js +1 -0
- package/dist/modules/admin-users/repository.d.ts +17 -0
- package/dist/modules/admin-users/schemas.d.ts +4 -0
- package/dist/modules/admin-users/schemas.js +6 -2
- package/dist/modules/auth/components/sign-in-form.js +10 -8
- package/dist/presentation/group.d.ts +27 -0
- package/dist/presentation/group.js +14 -0
- package/dist/presentation/group.module.js +6 -0
- package/dist/presentation/group_module.css +19 -0
- package/dist/presentation/row.d.ts +25 -0
- package/dist/presentation/row.js +8 -0
- package/dist/presentation/row.module.js +5 -0
- package/dist/presentation/row_module.css +18 -0
- package/dist/presentation/tabs.d.ts +25 -0
- package/dist/presentation/tabs.js +39 -0
- package/dist/presentation/tabs.module.js +10 -0
- package/dist/presentation/tabs_module.css +68 -0
- package/dist/react.d.ts +66 -0
- package/dist/react.js +36 -0
- package/dist/services/admin-services-types.d.ts +16 -0
- package/dist/widgets/diff-viewer/diff-modal.d.ts +22 -0
- package/dist/widgets/diff-viewer/diff-modal.js +149 -0
- package/dist/widgets/diff-viewer/diff-modal.module.js +14 -0
- package/dist/widgets/diff-viewer/diff-modal_module.css +56 -0
- package/dist/widgets/status-badge/status-badge.d.ts +25 -0
- package/dist/widgets/status-badge/status-badge.js +37 -0
- package/dist/widgets/status-badge/status-badge.module.js +7 -0
- package/dist/widgets/status-badge/status-badge_module.css +20 -0
- package/package.json +14 -4
- package/src/fields/array/array-field.module.css +48 -0
- package/src/fields/array/array-field.tsx +267 -0
- package/src/fields/blocks/blocks-field.module.css +148 -0
- package/src/fields/blocks/blocks-field.tsx +323 -0
- package/src/fields/checkbox/checkbox-field.module.css +4 -0
- package/src/fields/checkbox/checkbox-field.tsx +54 -0
- package/src/fields/column-formatter.tsx +31 -0
- package/src/fields/date-time-formatter.tsx +22 -0
- package/src/fields/datetime/datetime-field.module.css +13 -0
- package/src/fields/datetime/datetime-field.tsx +54 -0
- package/src/fields/draggable-context-menu.module.css +127 -0
- package/src/fields/draggable-context-menu.tsx +87 -0
- package/src/fields/field-helpers.ts +69 -0
- package/src/fields/field-renderer.module.css +22 -0
- package/src/fields/field-renderer.tsx +288 -0
- package/src/fields/field-services-context.tsx +35 -0
- package/src/fields/field-services-types.ts +68 -0
- package/src/fields/file/file-field.module.css +153 -0
- package/src/fields/file/file-field.tsx +286 -0
- package/src/fields/file/file-upload-field.module.css +101 -0
- package/src/fields/file/file-upload-field.tsx +187 -0
- package/src/fields/group/group-field.module.css +43 -0
- package/src/fields/group/group-field.tsx +84 -0
- package/src/fields/image/image-field.module.css +155 -0
- package/src/fields/image/image-field.tsx +306 -0
- package/src/fields/image/image-upload-field.module.css +123 -0
- package/src/fields/image/image-upload-field.tsx +276 -0
- package/src/fields/local-date-time.tsx +88 -0
- package/src/fields/locale-badge.module.css +37 -0
- package/src/fields/locale-badge.tsx +32 -0
- package/src/fields/numerical/numerical-field.tsx +114 -0
- package/src/fields/relation/relation-display.module.css +36 -0
- package/src/fields/relation/relation-display.tsx +138 -0
- package/src/fields/relation/relation-field.module.css +83 -0
- package/src/fields/relation/relation-field.tsx +211 -0
- package/src/fields/relation/relation-picker.module.css +168 -0
- package/src/fields/relation/relation-picker.tsx +343 -0
- package/src/fields/relation/relation-summary.module.css +55 -0
- package/src/fields/relation/relation-summary.tsx +123 -0
- package/src/fields/select/select-field.module.css +13 -0
- package/src/fields/select/select-field.tsx +61 -0
- package/src/fields/sortable-item.module.css +167 -0
- package/src/fields/sortable-item.tsx +106 -0
- package/src/fields/text/text-field.module.css +13 -0
- package/src/fields/text/text-field.tsx +146 -0
- package/src/fields/text-area/text-area-field.module.css +13 -0
- package/src/fields/text-area/text-area-field.tsx +147 -0
- package/src/fields/use-field-change-handler.ts +112 -0
- package/src/forms/document-actions.module.css +160 -0
- package/src/forms/document-actions.tsx +482 -0
- package/src/forms/form-context.tsx +704 -0
- package/src/forms/form-renderer.module.css +321 -0
- package/src/forms/form-renderer.tsx +891 -0
- package/src/forms/navigation-guard.tsx +98 -0
- package/src/forms/path-widget.module.css +41 -0
- package/src/forms/path-widget.test.tsx +217 -0
- package/src/forms/path-widget.tsx +183 -0
- package/src/forms/upload-executor.ts +192 -0
- package/src/lib/translate-validation-error.ts +56 -0
- package/src/modules/admin-account/commands.ts +13 -0
- package/src/modules/admin-account/components/change-password.tsx +46 -31
- package/src/modules/admin-account/components/container.tsx +83 -38
- package/src/modules/admin-account/components/preferences.module.css +60 -0
- package/src/modules/admin-account/components/preferences.tsx +203 -0
- package/src/modules/admin-account/components/update.tsx +53 -27
- package/src/modules/admin-account/index.ts +3 -0
- package/src/modules/admin-account/schemas.ts +13 -0
- package/src/modules/admin-account/service.ts +12 -0
- package/src/modules/admin-permissions/components/inspector.tsx +22 -14
- package/src/modules/admin-roles/components/create.tsx +51 -23
- package/src/modules/admin-roles/components/permissions.tsx +25 -21
- package/src/modules/admin-roles/components/update.tsx +37 -19
- package/src/modules/admin-users/components/create.tsx +63 -34
- package/src/modules/admin-users/components/roles.tsx +9 -8
- package/src/modules/admin-users/components/set-password.tsx +34 -28
- package/src/modules/admin-users/components/update.tsx +58 -36
- package/src/modules/admin-users/dto.ts +1 -0
- package/src/modules/admin-users/repository.ts +17 -0
- package/src/modules/admin-users/schemas.ts +12 -0
- package/src/modules/auth/components/sign-in-form.tsx +14 -8
- package/src/presentation/group.module.css +41 -0
- package/src/presentation/group.tsx +40 -0
- package/src/presentation/row.module.css +32 -0
- package/src/presentation/row.tsx +33 -0
- package/src/presentation/tabs.module.css +107 -0
- package/src/presentation/tabs.tsx +84 -0
- package/src/react.ts +84 -0
- package/src/services/admin-services-types.ts +18 -0
- package/src/widgets/diff-viewer/diff-modal.module.css +79 -0
- package/src/widgets/diff-viewer/diff-modal.tsx +186 -0
- package/src/widgets/status-badge/status-badge.module.css +31 -0
- 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
|
+
}
|