@byline/admin 4.15.0 → 4.17.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 (51) hide show
  1. package/dist/abilities.js +2 -0
  2. package/dist/fields/relation/relation-field.js +3 -2
  3. package/dist/fields/relation/relation-many-field.js +3 -2
  4. package/dist/fields/relation/relation-picker.d.ts +2 -2
  5. package/dist/fields/relation/relation-summary.d.ts +2 -2
  6. package/dist/forms/document-actions.js +30 -25
  7. package/dist/forms/form-renderer.d.ts +17 -3
  8. package/dist/forms/form-renderer.js +18 -9
  9. package/dist/forms/tree-placement-widget.js +3 -2
  10. package/dist/forms/use-form-layout.d.ts +3 -3
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/modules/analytics/abilities.d.ts +14 -0
  14. package/dist/modules/analytics/abilities.js +21 -0
  15. package/dist/modules/analytics/components/dashboard.d.ts +20 -0
  16. package/dist/modules/analytics/components/dashboard.js +308 -0
  17. package/dist/modules/analytics/components/dashboard.module.js +29 -0
  18. package/dist/modules/analytics/components/dashboard.test.node.d.ts +8 -0
  19. package/dist/modules/analytics/components/dashboard_module.css +268 -0
  20. package/dist/modules/analytics/components/timeseries.d.ts +59 -0
  21. package/dist/modules/analytics/components/timeseries.js +253 -0
  22. package/dist/modules/analytics/components/timeseries.module.js +17 -0
  23. package/dist/modules/analytics/components/timeseries_module.css +126 -0
  24. package/dist/modules/analytics/index.d.ts +9 -0
  25. package/dist/modules/analytics/index.js +1 -0
  26. package/dist/modules/analytics/types.d.ts +20 -0
  27. package/dist/modules/analytics/types.js +1 -0
  28. package/package.json +21 -11
  29. package/src/abilities.ts +2 -0
  30. package/src/fields/relation/relation-field.tsx +5 -3
  31. package/src/fields/relation/relation-many-field.tsx +5 -3
  32. package/src/fields/relation/relation-picker.tsx +2 -2
  33. package/src/fields/relation/relation-summary.tsx +2 -2
  34. package/src/forms/document-actions.test.tsx +157 -0
  35. package/src/forms/document-actions.tsx +93 -74
  36. package/src/forms/form-renderer-capabilities.test.tsx +155 -0
  37. package/src/forms/form-renderer-submit.test.tsx +194 -0
  38. package/src/forms/form-renderer.tsx +59 -25
  39. package/src/forms/tree-placement-widget.tsx +3 -2
  40. package/src/forms/use-form-layout.ts +3 -3
  41. package/src/index.ts +1 -0
  42. package/src/modules/analytics/abilities.ts +33 -0
  43. package/src/modules/analytics/components/dashboard.module.css +330 -0
  44. package/src/modules/analytics/components/dashboard.test.node.ts +193 -0
  45. package/src/modules/analytics/components/dashboard.tsx +371 -0
  46. package/src/modules/analytics/components/timeseries.module.css +151 -0
  47. package/src/modules/analytics/components/timeseries.tsx +332 -0
  48. package/src/modules/analytics/index.ts +14 -0
  49. package/src/modules/analytics/types.ts +31 -0
  50. package/dist/forms/__probe-nested.test.node.d.ts +0 -1
  51. package/dist/forms/__probe-two.test.node.d.ts +0 -1
@@ -0,0 +1,194 @@
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 { defineAdminConfig } from '@byline/core'
12
+ import { adminTranslations } from '@byline/i18n/admin'
13
+ import { I18nProvider } from '@byline/i18n/react'
14
+ import { createRoot, type Root } from 'react-dom/client'
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
16
+
17
+ import { BylineFieldServicesProvider } from '../fields/field-services-context'
18
+
19
+ vi.mock('@byline/ui/react', async (importOriginal) => {
20
+ const actual = await importOriginal<Record<string, unknown>>()
21
+ const Pass = ({ children }: any) => <div>{children}</div>
22
+ const Modal: any = ({ children, isOpen }: any) => (isOpen ? <div>{children}</div> : null)
23
+ Modal.Container = Pass
24
+ Modal.Header = Pass
25
+ Modal.Content = Pass
26
+ Modal.Actions = Pass
27
+ return {
28
+ ...actual,
29
+ Dropdown: {
30
+ Root: Pass,
31
+ Trigger: ({ children }: any) => <div data-testid="actions-trigger">{children}</div>,
32
+ Portal: Pass,
33
+ Content: Pass,
34
+ Item: ({ children, onClick }: any) => (
35
+ <div
36
+ onClick={onClick}
37
+ onKeyDown={(event) => {
38
+ if (event.key === 'Enter' || event.key === ' ') onClick?.(event)
39
+ }}
40
+ role="menuitem"
41
+ tabIndex={0}
42
+ >
43
+ {children}
44
+ </div>
45
+ ),
46
+ Separator: () => <hr />,
47
+ },
48
+ Modal,
49
+ Input: ({ name, value, onChange }: any) => (
50
+ <input name={name} value={value ?? ''} onChange={onChange} />
51
+ ),
52
+ }
53
+ })
54
+
55
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
56
+
57
+ defineAdminConfig({
58
+ i18n: {
59
+ admin: { defaultLocale: 'en', locales: ['en'] },
60
+ content: { defaultLocale: 'en', locales: ['en'] },
61
+ },
62
+ collections: [
63
+ {
64
+ path: 'pages',
65
+ labels: { singular: 'Page', plural: 'Pages' },
66
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
67
+ },
68
+ ],
69
+ slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
70
+ })
71
+
72
+ const fieldServices = {
73
+ getCollectionDocuments: async () => ({ docs: [], total: 0 }),
74
+ uploadField: async () => ({}),
75
+ } as any
76
+
77
+ let container: HTMLDivElement
78
+ let root: Root
79
+
80
+ beforeEach(() => {
81
+ container = document.createElement('div')
82
+ document.body.appendChild(container)
83
+ root = createRoot(container)
84
+ })
85
+
86
+ afterEach(() => {
87
+ act(() => {
88
+ root.unmount()
89
+ })
90
+ container.remove()
91
+ })
92
+
93
+ const renderInProvider = (element: React.ReactNode) => {
94
+ act(() => {
95
+ root.render(
96
+ <I18nProvider
97
+ bundle={adminTranslations({ locales: ['en'] })}
98
+ activeLocale="en"
99
+ defaultLocale="en"
100
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
101
+ >
102
+ <BylineFieldServicesProvider services={fieldServices}>
103
+ {element}
104
+ </BylineFieldServicesProvider>
105
+ </I18nProvider>
106
+ )
107
+ })
108
+ }
109
+
110
+ import { FormRenderer } from './form-renderer'
111
+
112
+ const fields = [{ name: 'title', label: 'Title', type: 'text' as const }]
113
+
114
+ const typeIntoTitle = (value: string) => {
115
+ const input = container.querySelector<HTMLInputElement>('input[name="title"]')
116
+ if (input == null) throw new Error('title input not found')
117
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
118
+ act(() => {
119
+ setter?.call(input, value)
120
+ input.dispatchEvent(new Event('input', { bubbles: true }))
121
+ })
122
+ }
123
+
124
+ const submitForm = () => {
125
+ const form = container.querySelector('form')
126
+ if (form == null) throw new Error('form not found')
127
+ act(() => {
128
+ form.requestSubmit()
129
+ })
130
+ }
131
+
132
+ describe('FormRenderer submit contract', () => {
133
+ const render = (props: Record<string, unknown>) =>
134
+ renderInProvider(<FormRenderer {...(props as any)} />)
135
+
136
+ const baseProps = {
137
+ mode: 'create' as const,
138
+ fields,
139
+ onCancel: () => {},
140
+ collectionPath: 'pages',
141
+ }
142
+
143
+ it('becomes dirty once a field is edited', () => {
144
+ render({ ...baseProps, onSubmit: async () => {} })
145
+ expect(container.textContent).toContain('Close')
146
+ typeIntoTitle('Hello')
147
+ expect(container.textContent).toContain('Cancel')
148
+ })
149
+
150
+ it('clears dirty state when onSubmit resolves', async () => {
151
+ const onSubmit = vi.fn(async () => {})
152
+ render({ ...baseProps, onSubmit })
153
+ typeIntoTitle('Hello')
154
+ submitForm()
155
+ await act(async () => {})
156
+ expect(onSubmit).toHaveBeenCalledTimes(1)
157
+ expect(container.textContent).toContain('Close')
158
+ })
159
+
160
+ it('preserves dirty state when onSubmit rejects', async () => {
161
+ const onSubmit = vi.fn(async () => {
162
+ throw new Error('save failed')
163
+ })
164
+ render({ ...baseProps, onSubmit })
165
+ typeIntoTitle('Hello')
166
+ submitForm()
167
+ await act(async () => {})
168
+ expect(onSubmit).toHaveBeenCalledTimes(1)
169
+ expect(container.textContent).toContain('Cancel')
170
+ })
171
+
172
+ it('ignores a second submit while the first is still in flight', async () => {
173
+ let release: () => void = () => {}
174
+ const gate = new Promise<void>((resolve) => {
175
+ release = resolve
176
+ })
177
+ const onSubmit = vi.fn(async () => {
178
+ await gate
179
+ })
180
+ render({ ...baseProps, onSubmit })
181
+ typeIntoTitle('Hello')
182
+ submitForm()
183
+ submitForm()
184
+ // Submission first awaits the field-hook pipeline, so flush that
185
+ // microtask before counting host calls. An immediate assertion here
186
+ // would observe zero calls and would not test duplicate suppression.
187
+ await act(async () => {})
188
+ expect(onSubmit).toHaveBeenCalledTimes(1)
189
+ await act(async () => {
190
+ release()
191
+ })
192
+ expect(onSubmit).toHaveBeenCalledTimes(1)
193
+ })
194
+ })
@@ -11,8 +11,8 @@
11
11
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
12
12
 
13
13
  import type {
14
- CollectionAdminConfig,
15
14
  Field,
15
+ FormAdminConfig,
16
16
  GroupDefinition,
17
17
  RowDefinition,
18
18
  TabSetDefinition,
@@ -82,7 +82,7 @@ export interface SystemFieldsSubmitPayload {
82
82
  export interface FormRendererProps {
83
83
  mode: 'create' | 'edit'
84
84
  fields: Field[]
85
- onSubmit: (data: any) => void
85
+ onSubmit: (data: any) => void | Promise<void>
86
86
  onCancel: () => void
87
87
  onStatusChange?: (nextStatus: string) => Promise<void>
88
88
  onUnpublish?: () => Promise<void>
@@ -124,7 +124,7 @@ export interface FormRendererProps {
124
124
  workflowStatuses?: WorkflowStatus[]
125
125
  publishedVersion?: PublishedVersionInfo | null
126
126
  initialData?: Record<string, any>
127
- adminConfig?: CollectionAdminConfig
127
+ adminConfig?: FormAdminConfig
128
128
  /**
129
129
  * Name of the schema field to render as the live form heading.
130
130
  * Sourced from `CollectionDefinition.useAsTitle` by the caller.
@@ -136,6 +136,20 @@ export interface FormRendererProps {
136
136
  * present the path widget renders in the sidebar.
137
137
  */
138
138
  useAsPath?: string
139
+ /**
140
+ * Whether the system path widget may render in the sidebar. Defaults to
141
+ * `true`, which preserves the historical behaviour of showing the widget
142
+ * whenever `useAsPath` is declared or the document envelope carries a
143
+ * `path`. Set `false` for a resource whose path is internal metadata and
144
+ * must never be presented or edited.
145
+ */
146
+ showPath?: boolean
147
+ /**
148
+ * Explicit form heading, used verbatim. Overrides both `useAsTitle`'s live
149
+ * value and the create/edit wording derived from `mode` — for a resource
150
+ * whose identity does not change when it is first materialised.
151
+ */
152
+ heading?: string
139
153
  /**
140
154
  * Opts the available-locales widget into the sidebar (below the path
141
155
  * widget). Sourced from `CollectionDefinition.advertiseLocales` by the
@@ -203,6 +217,8 @@ const FormContent = ({
203
217
  adminConfig,
204
218
  useAsTitle,
205
219
  useAsPath,
220
+ showPath = true,
221
+ heading,
206
222
  advertiseLocales,
207
223
  tree,
208
224
  headingLabel,
@@ -245,6 +261,8 @@ const FormContent = ({
245
261
  const [hasChanges, setHasChanges] = useState(hasChangesFn())
246
262
  const [statusBusy, setStatusBusy] = useState(false)
247
263
  const [isUploading, setIsUploading] = useState(false)
264
+ const submittingRef = useRef(false)
265
+ const [isSubmitting, setIsSubmitting] = useState(false)
248
266
  // Block-only "save first" guard. Set true when the editor triggers a
249
267
  // guarded action (status change, duplicate, copy-to-locale) while the form
250
268
  // is dirty — those actions operate on the saved version, so unsaved edits
@@ -336,7 +354,8 @@ const FormContent = ({
336
354
 
337
355
  // Live document heading — tracks the useAsTitle field as the user types
338
356
  const liveTitle = useFieldValue<string>(useAsTitle ?? '')
339
- const heading =
357
+ const computedHeading =
358
+ heading ||
340
359
  liveTitle ||
341
360
  (headingLabel
342
361
  ? mode === 'create'
@@ -379,13 +398,27 @@ const FormContent = ({
379
398
  }
380
399
  }
381
400
 
382
- // Emit the payload and optimistically clear dirty state (parity with the
383
- // prior submit behaviour the host surfaces failures via toast).
401
+ // Await the host handler. Resolution means the save succeeded and the clean
402
+ // baseline can be committed; rejection preserves dirty state so the editor
403
+ // does not lose work and the navigation guard keeps blocking. Host handlers
404
+ // that surface their own toast MUST rethrow afterwards.
405
+ // Re-entry guard lives in a ref so the callback identity does not change
406
+ // mid-flight; the mirrored state drives the Save button's disabled prop.
384
407
  const submitPayload = useCallback(
385
- (payload: SystemFieldsSubmitPayload) => {
386
- if (onSubmit && typeof onSubmit === 'function') {
387
- onSubmit(payload)
408
+ async (payload: SystemFieldsSubmitPayload) => {
409
+ if (typeof onSubmit !== 'function') return
410
+ if (submittingRef.current) return
411
+ submittingRef.current = true
412
+ setIsSubmitting(true)
413
+ try {
414
+ await onSubmit(payload)
388
415
  resetHasChanges()
416
+ } catch {
417
+ // Intentionally swallowed here — the host has already reported the
418
+ // failure to the user. Dirty state is preserved by not resetting.
419
+ } finally {
420
+ submittingRef.current = false
421
+ setIsSubmitting(false)
389
422
  }
390
423
  },
391
424
  [onSubmit, resetHasChanges]
@@ -482,7 +515,7 @@ const FormContent = ({
482
515
  return
483
516
  }
484
517
 
485
- submitPayload(payload)
518
+ await submitPayload(payload)
486
519
  })()
487
520
  }
488
521
 
@@ -582,7 +615,7 @@ const FormContent = ({
582
615
  aria-busy={isUploading}
583
616
  >
584
617
  <div className={cx('byline-form-heading-row', styles['heading-row'])}>
585
- <h1 className={cx('byline-form-heading', styles.heading)}>{heading}</h1>
618
+ <h1 className={cx('byline-form-heading', styles.heading)}>{computedHeading}</h1>
586
619
  {/* Source-locale anchor indicator removed pending heading-layout work.
587
620
  To re-enable: render `<SourceLocaleBadge locale={sourceLocale} />`
588
621
  here from `initialData.sourceLocale` (mismatch-only is the intended
@@ -615,7 +648,7 @@ const FormContent = ({
615
648
  className={cx('byline-form-actions-button', styles['actions-button'])}
616
649
  size="sm"
617
650
  type="submit"
618
- disabled={hasChanges === false || isUploading}
651
+ disabled={hasChanges === false || isUploading || isSubmitting}
619
652
  >
620
653
  {isUploading ? t('forms.actions.uploading') : t('common.actions.save')}
621
654
  </Button>
@@ -736,18 +769,19 @@ const FormContent = ({
736
769
  {layout.main.map((name) => renderItem(name))}
737
770
  </div>
738
771
  <div className={cx('byline-form-sidebar', styles.sidebar)}>
739
- {(useAsPath ||
740
- (typeof initialData?.path === 'string' && initialData.path.length > 0)) && (
741
- <PathWidget
742
- useAsPath={useAsPath}
743
- collectionPath={collectionPath ?? ''}
744
- defaultLocale={defaultLocale}
745
- activeLocale={contentLocale}
746
- mode={mode}
747
- slugifier={pathSlugifier}
748
- sourceLocked={pathSourceLocked}
749
- />
750
- )}
772
+ {showPath &&
773
+ (useAsPath ||
774
+ (typeof initialData?.path === 'string' && initialData.path.length > 0)) && (
775
+ <PathWidget
776
+ useAsPath={useAsPath}
777
+ collectionPath={collectionPath ?? ''}
778
+ defaultLocale={defaultLocale}
779
+ activeLocale={contentLocale}
780
+ mode={mode}
781
+ slugifier={pathSlugifier}
782
+ sourceLocked={pathSourceLocked}
783
+ />
784
+ )}
751
785
  {tree && mode === 'edit' && typeof initialData?.id === 'string' && (
752
786
  <TreePlacementWidget
753
787
  collectionPath={collectionPath ?? ''}
@@ -776,7 +810,7 @@ const FormContent = ({
776
810
  onConfirm={() => {
777
811
  const payload = pendingSystemFieldsSubmit
778
812
  setPendingSystemFieldsSubmit(null)
779
- submitPayload(payload)
813
+ void submitPayload(payload)
780
814
  }}
781
815
  />
782
816
  )}
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { useCallback, useEffect, useState } from 'react'
12
12
 
13
- import { getCollectionDefinition } from '@byline/core'
13
+ import { getCollectionDefinition, isSingleton } from '@byline/core'
14
14
  import { useTranslation } from '@byline/i18n/react'
15
15
  import { Button } from '@byline/ui/react'
16
16
  import cx from 'clsx'
@@ -49,7 +49,8 @@ export const TreePlacementWidget = ({
49
49
  }: TreePlacementWidgetProps) => {
50
50
  const { t } = useTranslation('byline-admin')
51
51
  const { getTreeAncestors, getTreeParent, placeTreeNode } = useBylineFieldServices()
52
- const targetDefinition = getCollectionDefinition(collectionPath)
52
+ const definition = getCollectionDefinition(collectionPath)
53
+ const targetDefinition = definition != null && !isSingleton(definition) ? definition : null
53
54
 
54
55
  const [parent, setParent] = useState<{ id: string; title: string } | null>(null)
55
56
  // Whether the document has an edge row at all. `false` = *unplaced* (no row);
@@ -11,8 +11,8 @@
11
11
  import { useMemo } from 'react'
12
12
 
13
13
  import type {
14
- CollectionAdminConfig,
15
14
  Field,
15
+ FormAdminConfig,
16
16
  GroupDefinition,
17
17
  LayoutDefinition,
18
18
  RowDefinition,
@@ -50,7 +50,7 @@ export interface FormLayout {
50
50
  * the `seen` set guards against a config that references a row/group cycle.
51
51
  */
52
52
  export function buildFieldToTabPath(
53
- adminConfig: CollectionAdminConfig | undefined,
53
+ adminConfig: FormAdminConfig | undefined,
54
54
  fieldByName: Map<string, Field>,
55
55
  rowByName: Map<string, RowDefinition>,
56
56
  groupByName: Map<string, GroupDefinition>
@@ -90,7 +90,7 @@ export function buildFieldToTabPath(
90
90
  * Rebuilt only when `adminConfig` / `fields` change.
91
91
  */
92
92
  export function useFormLayout(
93
- adminConfig: CollectionAdminConfig | undefined,
93
+ adminConfig: FormAdminConfig | undefined,
94
94
  fields: Field[]
95
95
  ): FormLayout {
96
96
  const fieldByName = useMemo(() => {
package/src/index.ts CHANGED
@@ -36,5 +36,6 @@ export * from './modules/admin-activity/index.js'
36
36
  export * from './modules/admin-permissions/index.js'
37
37
  export * from './modules/admin-roles/index.js'
38
38
  export * from './modules/admin-users/index.js'
39
+ export * from './modules/analytics/index.js'
39
40
  export * from './modules/auth/index.js'
40
41
  export type { AdminStore } from './store.js'
@@ -0,0 +1,33 @@
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 type { AbilityRegistry } from '@byline/auth'
10
+
11
+ export const ANALYTICS_ABILITIES = {
12
+ read: 'analytics.read',
13
+ maintain: 'analytics.maintain',
14
+ } as const
15
+
16
+ export type AnalyticsAbilityKey = (typeof ANALYTICS_ABILITIES)[keyof typeof ANALYTICS_ABILITIES]
17
+
18
+ export function registerAnalyticsAbilities(registry: AbilityRegistry): void {
19
+ registry.register({
20
+ key: ANALYTICS_ABILITIES.read,
21
+ label: 'Read analytics',
22
+ description: 'View installation-level page, visitor, download, referrer, and country totals.',
23
+ group: 'analytics',
24
+ source: 'admin',
25
+ })
26
+ registry.register({
27
+ key: ANALYTICS_ABILITIES.maintain,
28
+ label: 'Maintain analytics',
29
+ description: 'Delete retained analytics events and rebuild affected daily aggregates.',
30
+ group: 'analytics',
31
+ source: 'admin',
32
+ })
33
+ }