@byline/admin 5.1.3 → 5.2.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.
- package/dist/modules/analytics/components/dashboard.d.ts +13 -3
- package/dist/modules/analytics/components/dashboard.js +86 -68
- package/dist/modules/analytics/components/dashboard.module.js +10 -1
- package/dist/modules/analytics/components/dashboard_module.css +57 -0
- package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
- package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
- package/dist/modules/analytics/components/ranking.d.ts +45 -0
- package/dist/modules/analytics/components/ranking.js +71 -0
- package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -0
- package/package.json +14 -14
- package/src/modules/analytics/components/dashboard.module.css +69 -0
- package/src/modules/analytics/components/dashboard.tsx +105 -83
- package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
- package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
- package/src/modules/analytics/components/ranking.test.node.ts +43 -0
- package/src/modules/analytics/components/ranking.tsx +122 -0
- package/dist/exports-parity.test.node.js +0 -37
- package/dist/fields/array/array-field.jsx +0 -209
- package/dist/fields/blocks/blocks-field.jsx +0 -248
- package/dist/fields/checkbox/checkbox-field.jsx +0 -24
- package/dist/fields/code/code-editor.jsx +0 -211
- package/dist/fields/code/code-field.jsx +0 -109
- package/dist/fields/column-formatter.jsx +0 -25
- package/dist/fields/date-time-formatter.jsx +0 -18
- package/dist/fields/datetime/datetime-field.jsx +0 -22
- package/dist/fields/draggable-context-menu.jsx +0 -50
- package/dist/fields/field-admin.test.node.js +0 -40
- package/dist/fields/field-renderer.jsx +0 -136
- package/dist/fields/field-services-context.jsx +0 -17
- package/dist/fields/file/file-field.jsx +0 -176
- package/dist/fields/file/file-upload-field.jsx +0 -89
- package/dist/fields/group/group-field.jsx +0 -46
- package/dist/fields/image/image-field.jsx +0 -183
- package/dist/fields/image/image-upload-field.jsx +0 -157
- package/dist/fields/local-date-time.jsx +0 -69
- package/dist/fields/locale-badge.jsx +0 -19
- package/dist/fields/numerical/numerical-field.jsx +0 -93
- package/dist/fields/relation/relation-column-formatter.jsx +0 -74
- package/dist/fields/relation/relation-display.jsx +0 -118
- package/dist/fields/relation/relation-field.jsx +0 -108
- package/dist/fields/relation/relation-many-field.jsx +0 -142
- package/dist/fields/relation/relation-picker.jsx +0 -240
- package/dist/fields/relation/relation-summary.jsx +0 -45
- package/dist/fields/select/select-field.jsx +0 -27
- package/dist/fields/sortable-item.jsx +0 -54
- package/dist/fields/text/text-field.jsx +0 -79
- package/dist/fields/text-area/text-area-field.jsx +0 -79
- package/dist/forms/available-locales-reconcile.test.node.js +0 -43
- package/dist/forms/available-locales-widget.jsx +0 -63
- package/dist/forms/document-actions.jsx +0 -486
- package/dist/forms/form-context.jsx +0 -598
- package/dist/forms/form-modals.jsx +0 -128
- package/dist/forms/form-renderer.jsx +0 -532
- package/dist/forms/form-status-display.jsx +0 -69
- package/dist/forms/navigation-guard.jsx +0 -68
- package/dist/forms/nested-path.test.node.js +0 -115
- package/dist/forms/path-widget.jsx +0 -125
- package/dist/forms/pending-uploads.test.node.js +0 -19
- package/dist/forms/repeating-items.test.node.js +0 -29
- package/dist/forms/scheduled-publication-control.jsx +0 -389
- package/dist/forms/scheduled-publication-state.test.node.js +0 -121
- package/dist/forms/scheduled-publication-time.test.node.js +0 -70
- package/dist/forms/status-transitions.test.node.js +0 -76
- package/dist/forms/tree-placement-widget.jsx +0 -160
- package/dist/forms/upload-executor.test.node.js +0 -368
- package/dist/forms/use-form-layout.test.node.js +0 -69
- package/dist/modules/admin-account/components/change-password.jsx +0 -169
- package/dist/modules/admin-account/components/container.jsx +0 -164
- package/dist/modules/admin-account/components/preferences.jsx +0 -148
- package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
- package/dist/modules/admin-account/components/update.jsx +0 -186
- package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
- package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
- package/dist/modules/admin-roles/components/create.jsx +0 -155
- package/dist/modules/admin-roles/components/permissions.jsx +0 -239
- package/dist/modules/admin-roles/components/update.jsx +0 -148
- package/dist/modules/admin-users/components/create.jsx +0 -206
- package/dist/modules/admin-users/components/roles.jsx +0 -126
- package/dist/modules/admin-users/components/set-password.jsx +0 -133
- package/dist/modules/admin-users/components/update.jsx +0 -215
- package/dist/modules/analytics/components/dashboard.jsx +0 -208
- package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
- package/dist/modules/analytics/components/timeseries.jsx +0 -193
- package/dist/modules/auth/components/sign-in-form.jsx +0 -89
- package/dist/modules/auth/safe-redirect.test.node.js +0 -40
- package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
- package/dist/presentation/group.jsx +0 -26
- package/dist/presentation/row.jsx +0 -23
- package/dist/presentation/tabs.jsx +0 -39
- package/dist/services/admin-services-context.jsx +0 -17
- package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
- package/dist/widgets/status-badge/status-badge.jsx +0 -46
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
/**
|
|
3
|
-
* This Source Code is subject to the terms of the Mozilla Public
|
|
4
|
-
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
5
|
-
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
6
|
-
*
|
|
7
|
-
* Copyright (c) Infonomic Company Limited
|
|
8
|
-
*/
|
|
9
|
-
import { useTranslation } from '@byline/i18n/react';
|
|
10
|
-
import { DeleteIcon, Dropdown as DropdownMenu, EllipsisIcon, IconButton, PlusIcon, } from '@byline/ui/react';
|
|
11
|
-
import cx from 'clsx';
|
|
12
|
-
import styles from './draggable-context-menu.module.css';
|
|
13
|
-
export function DraggableContextMenu({ onAddBelow, onRemove, }) {
|
|
14
|
-
const { t } = useTranslation('byline-admin');
|
|
15
|
-
const itemClass = cx('byline-draggable-menu-item', styles.item);
|
|
16
|
-
const rowClass = cx('byline-draggable-menu-row', styles.row);
|
|
17
|
-
const iconSlotClass = cx('byline-draggable-menu-icon-slot', styles['icon-slot']);
|
|
18
|
-
const labelClass = cx('byline-draggable-menu-label', styles.label);
|
|
19
|
-
return (<DropdownMenu.Root modal={false}>
|
|
20
|
-
<DropdownMenu.Trigger render={<IconButton variant="text" size="sm" aria-label={t('fields.draggableMenu.triggerAriaLabel')}/>}>
|
|
21
|
-
<EllipsisIcon width="16px" height="16px"/>
|
|
22
|
-
</DropdownMenu.Trigger>
|
|
23
|
-
|
|
24
|
-
<DropdownMenu.Portal>
|
|
25
|
-
<DropdownMenu.Content align="end" sideOffset={0} className={cx('byline-draggable-menu', styles.menu)}>
|
|
26
|
-
<DropdownMenu.Item className={itemClass} onClick={onAddBelow}>
|
|
27
|
-
<div className={rowClass}>
|
|
28
|
-
<span className={iconSlotClass}>
|
|
29
|
-
<PlusIcon width="18px" height="18px"/>
|
|
30
|
-
</span>
|
|
31
|
-
<span className={labelClass}>{t('fields.draggableMenu.addBelow')}</span>
|
|
32
|
-
</div>
|
|
33
|
-
</DropdownMenu.Item>
|
|
34
|
-
<DropdownMenu.Separator className={cx('byline-draggable-menu-separator', styles.separator)}/>
|
|
35
|
-
<DropdownMenu.Item className={itemClass} onClick={onRemove}>
|
|
36
|
-
<div className={rowClass}>
|
|
37
|
-
<div className={rowClass}>
|
|
38
|
-
<span className={iconSlotClass}>
|
|
39
|
-
<DeleteIcon width="18px" height="18px" svgClassName={cx('byline-draggable-menu-delete-icon', styles['delete-icon'])}/>
|
|
40
|
-
</span>
|
|
41
|
-
<span className={cx('byline-draggable-menu-label byline-draggable-menu-label-danger', styles.label, styles['label-danger'])}>
|
|
42
|
-
{t('fields.draggableMenu.remove')}
|
|
43
|
-
</span>
|
|
44
|
-
</div>
|
|
45
|
-
</div>
|
|
46
|
-
</DropdownMenu.Item>
|
|
47
|
-
</DropdownMenu.Content>
|
|
48
|
-
</DropdownMenu.Portal>
|
|
49
|
-
</DropdownMenu.Root>);
|
|
50
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
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
|
-
import { describe, expect, it } from 'vitest';
|
|
9
|
-
import { sliceFieldAdmin } from './field-admin.js';
|
|
10
|
-
describe('sliceFieldAdmin', () => {
|
|
11
|
-
const answer = { editor: (() => null) };
|
|
12
|
-
const question = { components: {} };
|
|
13
|
-
it('returns undefined for an undefined map', () => {
|
|
14
|
-
expect(sliceFieldAdmin(undefined, 'faq')).toBeUndefined();
|
|
15
|
-
});
|
|
16
|
-
it('returns undefined when no entry addresses a descendant of the child', () => {
|
|
17
|
-
expect(sliceFieldAdmin({ faq: question, other: question }, 'faq')).toBeUndefined();
|
|
18
|
-
});
|
|
19
|
-
it('strips the child prefix from descendant keys', () => {
|
|
20
|
-
expect(sliceFieldAdmin({ 'faq.answer': answer, 'faq.question': question }, 'faq')).toEqual({
|
|
21
|
-
answer,
|
|
22
|
-
question,
|
|
23
|
-
});
|
|
24
|
-
});
|
|
25
|
-
it('keeps deeper paths dotted for the next level to slice', () => {
|
|
26
|
-
const map = { 'files.filesGroup.publicationFile': question };
|
|
27
|
-
const level1 = sliceFieldAdmin(map, 'files');
|
|
28
|
-
expect(level1).toEqual({ 'filesGroup.publicationFile': question });
|
|
29
|
-
expect(sliceFieldAdmin(level1, 'filesGroup')).toEqual({ publicationFile: question });
|
|
30
|
-
});
|
|
31
|
-
it('does not match on name prefixes that are not path segments', () => {
|
|
32
|
-
// 'faqExtra.answer' must not be sliced by child 'faq'.
|
|
33
|
-
expect(sliceFieldAdmin({ 'faqExtra.answer': answer }, 'faq')).toBeUndefined();
|
|
34
|
-
});
|
|
35
|
-
it('excludes the child’s own exact-name entry from the slice', () => {
|
|
36
|
-
expect(sliceFieldAdmin({ faq: question, 'faq.answer': answer }, 'faq')).toEqual({
|
|
37
|
-
answer,
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
});
|
|
@@ -1,136 +0,0 @@
|
|
|
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
|
-
import { getAdminConfig } from '@byline/core';
|
|
9
|
-
import cx from 'clsx';
|
|
10
|
-
import { useFormContext } from '../forms/form-context';
|
|
11
|
-
import { ArrayField } from './array/array-field';
|
|
12
|
-
import { BlocksField } from './blocks/blocks-field';
|
|
13
|
-
import { CheckboxField } from './checkbox/checkbox-field';
|
|
14
|
-
import { CodeField } from './code/code-field';
|
|
15
|
-
import { DateTimeField } from './datetime/datetime-field';
|
|
16
|
-
import styles from './field-renderer.module.css';
|
|
17
|
-
import { FileField } from './file/file-field';
|
|
18
|
-
import { GroupField } from './group/group-field';
|
|
19
|
-
import { ImageField } from './image/image-field';
|
|
20
|
-
import { LocaleBadge } from './locale-badge';
|
|
21
|
-
import { NumericalField } from './numerical/numerical-field';
|
|
22
|
-
import { RelationField } from './relation/relation-field';
|
|
23
|
-
import { RelationManyField } from './relation/relation-many-field';
|
|
24
|
-
import { SelectField } from './select/select-field';
|
|
25
|
-
import { TextField } from './text/text-field';
|
|
26
|
-
import { TextAreaField } from './text-area/text-area-field';
|
|
27
|
-
import { useFieldChangeHandler } from './use-field-change-handler';
|
|
28
|
-
import { useFieldCondition } from './use-field-condition';
|
|
29
|
-
export const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, contentLocale, components, editor, fieldAdmin, }) => {
|
|
30
|
-
const path = basePath ? `${basePath}.${field.name}` : field.name;
|
|
31
|
-
const htmlId = path.replace(/[[\].]/g, '-');
|
|
32
|
-
const handleChange = useFieldChangeHandler(field, path);
|
|
33
|
-
const { getFieldValue } = useFormContext();
|
|
34
|
-
// Conditional visibility (BaseField.condition) — re-evaluated on every form
|
|
35
|
-
// edit; the field unmounts while its condition is false.
|
|
36
|
-
const visible = useFieldCondition(field, basePath);
|
|
37
|
-
// Conditional fields unmount while hidden, so on re-show the uncontrolled
|
|
38
|
-
// widget must be re-seeded from the live form store (which survives the
|
|
39
|
-
// unmount) rather than the initial document data — otherwise an edit made
|
|
40
|
-
// before hiding would be visually reverted while the store (and the patch
|
|
41
|
-
// stream) still carried it. `undefined` means the path was never written,
|
|
42
|
-
// in which case the initial default stands.
|
|
43
|
-
const storedValue = field.condition ? getFieldValue(path) : undefined;
|
|
44
|
-
const defaultValue = storedValue !== undefined ? storedValue : initialDefault;
|
|
45
|
-
// When a locale is active and the field is localised, inject a badge into
|
|
46
|
-
// the field label so the editor knows they are editing locale-specific content.
|
|
47
|
-
const isLocalised = field.localized === true;
|
|
48
|
-
const badge = isLocalised && contentLocale && !hideLabel ? <LocaleBadge locale={contentLocale}/> : null;
|
|
49
|
-
// All hooks have run by this point, so a conditional bail-out is safe.
|
|
50
|
-
// Hiding retains the field's stored value — no clearing patch is emitted.
|
|
51
|
-
if (!visible)
|
|
52
|
-
return null;
|
|
53
|
-
/**
|
|
54
|
-
* Render the underlying field widget. If the field is localised, we wrap it
|
|
55
|
-
* so we can append the locale badge after the label.
|
|
56
|
-
*/
|
|
57
|
-
const renderField = () => {
|
|
58
|
-
switch (field.type) {
|
|
59
|
-
case 'text':
|
|
60
|
-
return (<TextField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId} locale={isLocalised ? contentLocale : undefined} components={components}/>);
|
|
61
|
-
case 'textArea':
|
|
62
|
-
return (<TextAreaField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId} locale={isLocalised ? contentLocale : undefined} components={components}/>);
|
|
63
|
-
case 'code':
|
|
64
|
-
return (<CodeField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId} locale={isLocalised ? contentLocale : undefined} components={components}/>);
|
|
65
|
-
case 'checkbox':
|
|
66
|
-
return (<CheckboxField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId}/>);
|
|
67
|
-
case 'select':
|
|
68
|
-
return (<SelectField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId}/>);
|
|
69
|
-
case 'richText': {
|
|
70
|
-
// Admin-side per-field override takes precedence over the globally
|
|
71
|
-
// registered editor (`AdminConfig.fields.richText.editor`). The
|
|
72
|
-
// override travels via `FieldAdminConfig.fields.<name>.editor` —
|
|
73
|
-
// see admin-types — so React component references stay out of the
|
|
74
|
-
// schema graph that's loaded by the server bootstrap.
|
|
75
|
-
const RichTextEditor = editor ?? getAdminConfig().fields?.richText?.editor;
|
|
76
|
-
if (!RichTextEditor) {
|
|
77
|
-
throw new Error('No richText editor registered. Install @byline/richtext-lexical and set ' +
|
|
78
|
-
'`fields.richText.editor` in your admin config.');
|
|
79
|
-
}
|
|
80
|
-
return (<RichTextEditor field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} instanceKey={htmlId} locale={isLocalised ? contentLocale : undefined}/>);
|
|
81
|
-
}
|
|
82
|
-
case 'datetime':
|
|
83
|
-
return (<DateTimeField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId}/>);
|
|
84
|
-
case 'integer':
|
|
85
|
-
case 'float':
|
|
86
|
-
case 'decimal':
|
|
87
|
-
return (<NumericalField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId} components={components}/>);
|
|
88
|
-
case 'counter':
|
|
89
|
-
// Counter values are allocator-assigned; force readOnly at the
|
|
90
|
-
// renderer level so the widget is always non-editable regardless
|
|
91
|
-
// of whether the developer set `field.readOnly` explicitly.
|
|
92
|
-
return (<NumericalField field={hideLabel
|
|
93
|
-
? { ...field, label: undefined, readOnly: true }
|
|
94
|
-
: { ...field, readOnly: true }} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId} components={components}/>);
|
|
95
|
-
case 'file':
|
|
96
|
-
return (<FileField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path}/>);
|
|
97
|
-
case 'image':
|
|
98
|
-
return (<ImageField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path}/>);
|
|
99
|
-
case 'relation':
|
|
100
|
-
if (field.hasMany) {
|
|
101
|
-
return (<RelationManyField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} path={path} id={htmlId}/>);
|
|
102
|
-
}
|
|
103
|
-
return (<RelationField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId}/>);
|
|
104
|
-
case 'group':
|
|
105
|
-
// Render a group field as a fixed-order inline field group.
|
|
106
|
-
return (<GroupField field={hideLabel
|
|
107
|
-
? { ...field, label: undefined }
|
|
108
|
-
: field} defaultValue={defaultValue} path={path} disableSorting={disableSorting} contentLocale={contentLocale} fieldAdmin={fieldAdmin}/>);
|
|
109
|
-
case 'blocks':
|
|
110
|
-
if (!field.blocks)
|
|
111
|
-
return null;
|
|
112
|
-
return (<BlocksField field={field} defaultValue={defaultValue} path={path} contentLocale={contentLocale}/>);
|
|
113
|
-
case 'array':
|
|
114
|
-
if (!field.fields)
|
|
115
|
-
return null;
|
|
116
|
-
return (<ArrayField field={field} defaultValue={defaultValue} path={path} disableSorting={disableSorting} contentLocale={contentLocale} fieldAdmin={fieldAdmin}/>);
|
|
117
|
-
default:
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
};
|
|
121
|
-
// text, textArea, and code render the badge inside their own Label row;
|
|
122
|
-
// the outer wrapper is only needed for other field types.
|
|
123
|
-
const selfBadge = field.type === 'text' ||
|
|
124
|
-
field.type === 'textArea' ||
|
|
125
|
-
field.type === 'code' ||
|
|
126
|
-
field.type === 'richText';
|
|
127
|
-
if (badge && !selfBadge) {
|
|
128
|
-
return (<div className={cx('byline-field-localized-wrap', styles['localized-wrap'])}>
|
|
129
|
-
{renderField()}
|
|
130
|
-
<span className={cx('byline-field-localized-badge', styles['localized-badge'])}>
|
|
131
|
-
{badge}
|
|
132
|
-
</span>
|
|
133
|
-
</div>);
|
|
134
|
-
}
|
|
135
|
-
return renderField();
|
|
136
|
-
};
|
|
@@ -1,17 +0,0 @@
|
|
|
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
|
-
import { createContext, useContext } from 'react';
|
|
9
|
-
const FieldServicesContext = createContext(null);
|
|
10
|
-
export const BylineFieldServicesProvider = ({ services, children, }) => (<FieldServicesContext.Provider value={services}>{children}</FieldServicesContext.Provider>);
|
|
11
|
-
export const useBylineFieldServices = () => {
|
|
12
|
-
const ctx = useContext(FieldServicesContext);
|
|
13
|
-
if (!ctx) {
|
|
14
|
-
throw new Error('@byline/ui: BylineFieldServicesProvider missing. Wrap your admin tree with <BylineFieldServicesProvider services={…} />.');
|
|
15
|
-
}
|
|
16
|
-
return ctx;
|
|
17
|
-
};
|
|
@@ -1,176 +0,0 @@
|
|
|
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
|
-
import { isPendingStoredFileValue, } from '@byline/core';
|
|
9
|
-
import { useTranslation } from '@byline/i18n/react';
|
|
10
|
-
import { CloseIcon, DocumentIcon, DownloadIcon, ErrorText, HelpText, IconButton, Label, LoaderRing, VideoIcon, } from '@byline/ui/react';
|
|
11
|
-
import cx from 'clsx';
|
|
12
|
-
import { useFieldError, useFieldValue, useFormContext, useIsDirty, useIsFieldUploading, } from '../../forms/form-context';
|
|
13
|
-
import { useFieldChangeHandler } from '../use-field-change-handler';
|
|
14
|
-
import styles from './file-field.module.css';
|
|
15
|
-
import { FileUploadField } from './file-upload-field';
|
|
16
|
-
/**
|
|
17
|
-
* Trigger a download via a temporary anchor. Mirrors the helper in
|
|
18
|
-
* `image-lightbox.tsx`: same-origin URLs respect the `download` attribute and
|
|
19
|
-
* save with the suggested filename; cross-origin URLs without CORS headers
|
|
20
|
-
* fall through to navigation in a new tab, where the user can right-click
|
|
21
|
-
* Save As.
|
|
22
|
-
*/
|
|
23
|
-
function triggerDownload(url, filename) {
|
|
24
|
-
if (typeof document === 'undefined')
|
|
25
|
-
return;
|
|
26
|
-
const a = document.createElement('a');
|
|
27
|
-
a.href = url;
|
|
28
|
-
if (filename)
|
|
29
|
-
a.download = filename;
|
|
30
|
-
a.target = '_blank';
|
|
31
|
-
a.rel = 'noreferrer';
|
|
32
|
-
document.body.appendChild(a);
|
|
33
|
-
a.click();
|
|
34
|
-
document.body.removeChild(a);
|
|
35
|
-
}
|
|
36
|
-
export const FileField = ({ field, value, defaultValue, onChange: _onChange, path, }) => {
|
|
37
|
-
const fieldPath = path ?? field.name;
|
|
38
|
-
const { t } = useTranslation('byline-admin');
|
|
39
|
-
const fieldError = useFieldError(fieldPath);
|
|
40
|
-
const isDirty = useIsDirty(fieldPath);
|
|
41
|
-
const fieldValue = useFieldValue(fieldPath);
|
|
42
|
-
const isUploading = useIsFieldUploading(fieldPath);
|
|
43
|
-
// `collectionPath` comes from form context rather than a prop: it is
|
|
44
|
-
// constant for the form, and prop-drilling it meant any container that
|
|
45
|
-
// forgot to forward it silently rendered this widget read-only.
|
|
46
|
-
const { removePendingUpload, documentId, collectionPath } = useFormContext();
|
|
47
|
-
const handleChange = useFieldChangeHandler(field, fieldPath);
|
|
48
|
-
// Mirror the image-field rule: once the field has been touched, the form
|
|
49
|
-
// value is authoritative (even when null, so a click-to-remove sticks);
|
|
50
|
-
// otherwise fall back to props.
|
|
51
|
-
const incomingValue = isDirty
|
|
52
|
-
? (fieldValue ?? null)
|
|
53
|
-
: (value ?? fieldValue ?? defaultValue ?? null);
|
|
54
|
-
const isPending = isPendingStoredFileValue(incomingValue);
|
|
55
|
-
// Legacy placeholder shape — kept for backwards compatibility with older
|
|
56
|
-
// seed data, matching the image-field check.
|
|
57
|
-
const isOldPlaceholder = (v) => {
|
|
58
|
-
if (!v || typeof v !== 'object')
|
|
59
|
-
return false;
|
|
60
|
-
const maybe = v;
|
|
61
|
-
return maybe.storageProvider === 'placeholder' && maybe.storagePath === 'pending';
|
|
62
|
-
};
|
|
63
|
-
const showUploadWidget = incomingValue == null || isOldPlaceholder(incomingValue);
|
|
64
|
-
// `upload.requireSavedDocument` gate: until the document is persisted,
|
|
65
|
-
// render a "save first" notice in place of the upload zone. Server-side
|
|
66
|
-
// upload hooks that depend on save-time state (counters, document id)
|
|
67
|
-
// rely on this; existing stored values still render normally below.
|
|
68
|
-
const uploadGated = field.upload?.requireSavedDocument === true && documentId == null;
|
|
69
|
-
const handleRemove = () => {
|
|
70
|
-
if (isPending) {
|
|
71
|
-
removePendingUpload(fieldPath);
|
|
72
|
-
}
|
|
73
|
-
handleChange(null);
|
|
74
|
-
};
|
|
75
|
-
// MIME-driven glyph dispatch. Until a dedicated VideoField primitive lands,
|
|
76
|
-
// the FileField is the canonical home for video uploads — the schema's
|
|
77
|
-
// `upload.allowedMimeTypes` decides what gets in, and we swap the glyph
|
|
78
|
-
// here based on the resolved MIME so the tile reads as "video" rather
|
|
79
|
-
// than "generic document".
|
|
80
|
-
const isVideo = incomingValue?.mimeType?.startsWith('video/') === true;
|
|
81
|
-
const FileGlyph = isVideo ? VideoIcon : DocumentIcon;
|
|
82
|
-
const htmlId = fieldPath;
|
|
83
|
-
return (<div className={`byline-field-file ${field.name}`}>
|
|
84
|
-
<div className={cx('byline-field-file-header', styles.header)}>
|
|
85
|
-
<Label id={htmlId} htmlFor={htmlId} label={field.label ?? field.name} required={!field.optional}/>
|
|
86
|
-
</div>
|
|
87
|
-
|
|
88
|
-
{showUploadWidget ? (uploadGated ? (<div className={cx('byline-field-file-empty', styles.empty)} role="note" data-testid="upload-require-saved-document">
|
|
89
|
-
{t('fields.upload.requireSavedDocument')}
|
|
90
|
-
</div>) : collectionPath ? (<FileUploadField field={field} collectionPath={collectionPath} fieldPath={fieldPath} onUploaded={(uploaded) => {
|
|
91
|
-
handleChange(uploaded);
|
|
92
|
-
}}/>) : (<div className={cx('byline-field-file-empty', styles.empty)}>
|
|
93
|
-
{t('fields.file.empty')}
|
|
94
|
-
</div>)) : (<div className={cx('byline-field-file-tile', styles.tile)}>
|
|
95
|
-
{isUploading && (<div className={cx('byline-field-file-uploading', styles.uploading)} aria-live="polite" aria-busy="true">
|
|
96
|
-
<LoaderRing />
|
|
97
|
-
</div>)}
|
|
98
|
-
{collectionPath && (<div className={cx('byline-field-file-actions', styles.actions)}>
|
|
99
|
-
{!isPending && incomingValue?.storageUrl && (<IconButton type="button" intent="noeffect" onClick={() => triggerDownload(incomingValue.storageUrl, incomingValue.originalFilename ?? incomingValue.filename)} size="xs" disabled={isUploading} aria-label={t('fields.file.downloadAriaLabel')}>
|
|
100
|
-
<DownloadIcon width="15px" height="15px"/>
|
|
101
|
-
</IconButton>)}
|
|
102
|
-
<IconButton type="button" intent="noeffect" onClick={handleRemove} size="xs" disabled={isUploading} aria-label={t('fields.file.removeAriaLabel')}>
|
|
103
|
-
<CloseIcon width="15px" height="15px"/>
|
|
104
|
-
</IconButton>
|
|
105
|
-
</div>)}
|
|
106
|
-
{/* Document icon + (optional) pending badge — mirrors the
|
|
107
|
-
image-field's preview-wrap so the file tile has the same
|
|
108
|
-
visual hierarchy: glyph on the left, metadata on the right.
|
|
109
|
-
When the file is stored (non-pending and resolvable storageUrl),
|
|
110
|
-
the wrap is rendered as an anchor that opens the asset in a new
|
|
111
|
-
tab — browser-native viewer dispatch (PDFs render inline,
|
|
112
|
-
non-renderable types fall through to download). */}
|
|
113
|
-
{!isPending && incomingValue?.storageUrl ? (<a href={incomingValue.storageUrl} target="_blank" rel="noreferrer" aria-label={t('fields.file.openInNewTabAriaLabel', {
|
|
114
|
-
filename: incomingValue.originalFilename ?? incomingValue.filename,
|
|
115
|
-
})} className={cx('byline-field-file-icon-wrap', styles['icon-wrap'])}>
|
|
116
|
-
<FileGlyph width="48px" height="48px" className={cx('byline-field-file-icon', styles.icon)}/>
|
|
117
|
-
</a>) : (<div className={cx('byline-field-file-icon-wrap', styles['icon-wrap'])}>
|
|
118
|
-
<FileGlyph width="48px" height="48px" className={cx('byline-field-file-icon', styles.icon)}/>
|
|
119
|
-
{isPending && (<div className={cx('byline-field-file-pending', styles.pending)}>
|
|
120
|
-
{t('fields.fileMeta.pendingUpload')}
|
|
121
|
-
</div>)}
|
|
122
|
-
</div>)}
|
|
123
|
-
<div className={cx('byline-field-file-meta', styles.meta)}>
|
|
124
|
-
<div>
|
|
125
|
-
<span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
|
|
126
|
-
{t('fields.fileMeta.filename')}
|
|
127
|
-
</span>{' '}
|
|
128
|
-
{incomingValue?.filename}
|
|
129
|
-
</div>
|
|
130
|
-
<div>
|
|
131
|
-
<span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
|
|
132
|
-
{t('fields.fileMeta.original')}
|
|
133
|
-
</span>{' '}
|
|
134
|
-
{incomingValue?.originalFilename}
|
|
135
|
-
</div>
|
|
136
|
-
<div>
|
|
137
|
-
<span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
|
|
138
|
-
{t('fields.fileMeta.type')}
|
|
139
|
-
</span>{' '}
|
|
140
|
-
{incomingValue?.mimeType}
|
|
141
|
-
</div>
|
|
142
|
-
<div>
|
|
143
|
-
<span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
|
|
144
|
-
{t('fields.fileMeta.size')}
|
|
145
|
-
</span>{' '}
|
|
146
|
-
{incomingValue?.fileSize}
|
|
147
|
-
</div>
|
|
148
|
-
{isPending ? (<div>
|
|
149
|
-
<span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
|
|
150
|
-
{t('fields.fileMeta.status')}
|
|
151
|
-
</span>{' '}
|
|
152
|
-
<span className={cx('byline-field-file-meta-pending', styles['meta-pending'])}>
|
|
153
|
-
{t('fields.fileMeta.willUploadOnSave')}
|
|
154
|
-
</span>
|
|
155
|
-
</div>) : (<>
|
|
156
|
-
<div>
|
|
157
|
-
<span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
|
|
158
|
-
{t('fields.fileMeta.storage')}
|
|
159
|
-
</span>{' '}
|
|
160
|
-
{incomingValue?.storageProvider}
|
|
161
|
-
</div>
|
|
162
|
-
<div>
|
|
163
|
-
<span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
|
|
164
|
-
{t('fields.fileMeta.path')}
|
|
165
|
-
</span>{' '}
|
|
166
|
-
{incomingValue?.storagePath}
|
|
167
|
-
</div>
|
|
168
|
-
</>)}
|
|
169
|
-
</div>
|
|
170
|
-
</div>)}
|
|
171
|
-
|
|
172
|
-
{field.helpText && <HelpText text={field.helpText}/>}
|
|
173
|
-
|
|
174
|
-
{fieldError && <ErrorText id={`${field.name}-error`} text={fieldError}/>}
|
|
175
|
-
</div>);
|
|
176
|
-
};
|
|
@@ -1,89 +0,0 @@
|
|
|
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
|
-
import { useCallback, useRef, useState } from 'react';
|
|
9
|
-
import { createPendingStoredFileValue, } from '@byline/core';
|
|
10
|
-
import { useTranslation } from '@byline/i18n/react';
|
|
11
|
-
import cx from 'clsx';
|
|
12
|
-
import { useFormContext } from '../../forms/form-context';
|
|
13
|
-
import styles from './file-upload-field.module.css';
|
|
14
|
-
export const FileUploadField = ({ field: _field, collectionPath, fieldPath, onUploaded, accept, }) => {
|
|
15
|
-
const inputRef = useRef(null);
|
|
16
|
-
const { t } = useTranslation('byline-admin');
|
|
17
|
-
const [status, setStatus] = useState('idle');
|
|
18
|
-
const [errorMessage, setErrorMessage] = useState(null);
|
|
19
|
-
const [isDragOver, setIsDragOver] = useState(false);
|
|
20
|
-
const { addPendingUpload } = useFormContext();
|
|
21
|
-
const handleFileSelected = useCallback((file) => {
|
|
22
|
-
setStatus('processing');
|
|
23
|
-
setErrorMessage(null);
|
|
24
|
-
// Blob URL is created so the form orchestrator can revoke it on cleanup
|
|
25
|
-
// alongside image-field uploads; it isn't surfaced in the UI here.
|
|
26
|
-
const previewUrl = URL.createObjectURL(file);
|
|
27
|
-
const pendingValue = createPendingStoredFileValue(file, previewUrl);
|
|
28
|
-
if (!addPendingUpload(fieldPath, {
|
|
29
|
-
file,
|
|
30
|
-
previewUrl,
|
|
31
|
-
collectionPath,
|
|
32
|
-
})) {
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
setStatus('idle');
|
|
36
|
-
onUploaded(pendingValue);
|
|
37
|
-
}, [collectionPath, fieldPath, addPendingUpload, onUploaded]);
|
|
38
|
-
const handleFileChange = useCallback((e) => {
|
|
39
|
-
const file = e.target.files?.[0];
|
|
40
|
-
if (file)
|
|
41
|
-
handleFileSelected(file);
|
|
42
|
-
// Reset so re-selecting the same file fires the event again.
|
|
43
|
-
e.target.value = '';
|
|
44
|
-
}, [handleFileSelected]);
|
|
45
|
-
const handleBrowseClick = useCallback(() => {
|
|
46
|
-
inputRef.current?.click();
|
|
47
|
-
}, []);
|
|
48
|
-
const handleDragOver = useCallback((e) => {
|
|
49
|
-
e.preventDefault();
|
|
50
|
-
setIsDragOver(true);
|
|
51
|
-
}, []);
|
|
52
|
-
const handleDragLeave = useCallback((e) => {
|
|
53
|
-
e.preventDefault();
|
|
54
|
-
setIsDragOver(false);
|
|
55
|
-
}, []);
|
|
56
|
-
const handleDrop = useCallback((e) => {
|
|
57
|
-
e.preventDefault();
|
|
58
|
-
setIsDragOver(false);
|
|
59
|
-
const file = e.dataTransfer.files?.[0];
|
|
60
|
-
if (file)
|
|
61
|
-
handleFileSelected(file);
|
|
62
|
-
}, [handleFileSelected]);
|
|
63
|
-
const isProcessing = status === 'processing';
|
|
64
|
-
return (<div className={cx('byline-field-file-upload', styles.root)}>
|
|
65
|
-
<input ref={inputRef} type="file" accept={accept} className={cx('byline-field-file-upload-input', styles.input)} onChange={handleFileChange} disabled={isProcessing} aria-hidden="true" tabIndex={-1}/>
|
|
66
|
-
|
|
67
|
-
<div role="button" tabIndex={0} aria-label={t('fields.file.upload.zoneAriaLabel')} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={handleBrowseClick} onKeyDown={(e) => {
|
|
68
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
69
|
-
e.preventDefault();
|
|
70
|
-
handleBrowseClick();
|
|
71
|
-
}
|
|
72
|
-
}} className={cx('byline-field-file-upload-zone', styles.zone, isDragOver &&
|
|
73
|
-
!isProcessing && ['byline-field-file-upload-zone-active', styles['zone-active']], isProcessing && ['byline-field-file-upload-zone-busy', styles['zone-busy']])}>
|
|
74
|
-
<svg xmlns="http://www.w3.org/2000/svg" className={cx('byline-field-file-upload-icon', styles.icon)} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
|
75
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
|
|
76
|
-
</svg>
|
|
77
|
-
<span className={cx('byline-field-file-upload-label', styles.label)}>
|
|
78
|
-
{t('fields.file.upload.label')}{' '}
|
|
79
|
-
<span className={cx('byline-field-file-upload-action', styles.action)}>
|
|
80
|
-
{t('fields.file.upload.browse')}
|
|
81
|
-
</span>
|
|
82
|
-
</span>
|
|
83
|
-
</div>
|
|
84
|
-
|
|
85
|
-
{status === 'error' && errorMessage && (<p className={cx('byline-field-file-upload-error', styles.error)} role="alert">
|
|
86
|
-
{errorMessage}
|
|
87
|
-
</p>)}
|
|
88
|
-
</div>);
|
|
89
|
-
};
|
|
@@ -1,46 +0,0 @@
|
|
|
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
|
-
import { useMemo } from 'react';
|
|
9
|
-
import { ErrorText } from '@byline/ui/react';
|
|
10
|
-
import cx from 'clsx';
|
|
11
|
-
import { sliceFieldAdmin } from '../../fields/field-admin';
|
|
12
|
-
import { placeholderForField } from '../../fields/field-helpers';
|
|
13
|
-
import { FieldRenderer } from '../../fields/field-renderer';
|
|
14
|
-
import { useFieldError } from '../../forms/form-context';
|
|
15
|
-
import styles from './group-field.module.css';
|
|
16
|
-
export const GroupField = ({ field, defaultValue, path, disableSorting = true, contentLocale, fieldAdmin, }) => {
|
|
17
|
-
const fieldError = useFieldError(field.name);
|
|
18
|
-
// Default value for a group field is a plain object: { rating: 5, comment: '...' }
|
|
19
|
-
// Normalize to a plain object if not already one.
|
|
20
|
-
const groupData = useMemo(() => {
|
|
21
|
-
if (defaultValue && typeof defaultValue === 'object' && !Array.isArray(defaultValue)) {
|
|
22
|
-
return defaultValue;
|
|
23
|
-
}
|
|
24
|
-
// Fallback: build a placeholder object from child field definitions
|
|
25
|
-
const placeholder = {};
|
|
26
|
-
for (const childField of field.fields) {
|
|
27
|
-
placeholder[childField.name] = placeholderForField(childField);
|
|
28
|
-
}
|
|
29
|
-
return placeholder;
|
|
30
|
-
}, [defaultValue, field.fields]);
|
|
31
|
-
return (<div className={`byline-field-group ${field.name}`}>
|
|
32
|
-
{field.label && (<div className={cx('byline-field-group-header', styles.header)}>
|
|
33
|
-
<h3 className={cx('byline-field-group-title', styles.title)}>
|
|
34
|
-
{field.label}{' '}
|
|
35
|
-
{!field.optional && (<span className={cx('byline-field-group-required', styles.required)}>*</span>)}
|
|
36
|
-
</h3>
|
|
37
|
-
{field.helpText && (<p className={cx('byline-field-group-help', styles.help)}>{field.helpText}</p>)}
|
|
38
|
-
</div>)}
|
|
39
|
-
<div className={cx('byline-field-group-body', styles.body)}>
|
|
40
|
-
{field.fields.map((innerField) => {
|
|
41
|
-
return (<FieldRenderer key={innerField.name} field={innerField} defaultValue={groupData[innerField.name]} basePath={path} disableSorting={disableSorting} contentLocale={contentLocale} components={fieldAdmin?.[innerField.name]?.components} editor={fieldAdmin?.[innerField.name]?.editor} fieldAdmin={sliceFieldAdmin(fieldAdmin, innerField.name)}/>);
|
|
42
|
-
})}
|
|
43
|
-
</div>
|
|
44
|
-
{fieldError && <ErrorText id={`${field.name}-error`} text={fieldError}/>}
|
|
45
|
-
</div>);
|
|
46
|
-
};
|