Skip to content

Commit 2a69e58

Browse files
committed
chore: add field editor
1 parent 252c089 commit 2a69e58

File tree

15 files changed

+462
-23
lines changed

15 files changed

+462
-23
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,33 @@
11
export function isObject(data: any): boolean {
22
return typeof data === 'object' && !Array.isArray(data) && data !== null;
33
}
4+
5+
/**
6+
* Recursively sets data to an object using dot notation, e.g.
7+
* ```
8+
* setDeepKey({}, 'foo.bar', 'value');
9+
* // => {foo: {bar: 'value'}}
10+
* ```
11+
*/
12+
export function setDeepKey(data: any, deepKey: string, value: any) {
13+
if (deepKey.includes('.')) {
14+
const [head, tail] = splitKey(deepKey);
15+
data[head] ??= {};
16+
setDeepKey(data[head], tail, value);
17+
} else {
18+
const key = deepKey;
19+
if (typeof value === 'undefined') {
20+
delete data[key];
21+
} else {
22+
data[key] = value;
23+
}
24+
}
25+
return data;
26+
}
27+
28+
function splitKey(key: string) {
29+
const index = key.indexOf('.');
30+
const head = key.substring(0, index);
31+
const tail = key.substring(index + 1);
32+
return [head, tail] as const;
33+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.FieldEditor__FieldHeader {
2+
position: relative;
3+
padding-right: 30px;
4+
margin-bottom: 8px;
5+
}
6+
7+
.FieldEditor__FieldHeader__label {
8+
font-size: 12px;
9+
font-weight: 600;
10+
display: flex;
11+
gap: 8px;
12+
}
13+
14+
.FieldEditor__FieldHeader__help {
15+
font-size: 12px;
16+
font-weight: 400;
17+
margin-top: 2px;
18+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import * as schema from '../../../core/schema.js';
2+
import {joinClassNames} from '../../utils/classes.js';
3+
import {StringField} from './fields/StringField.js';
4+
import {FieldEditorProvider} from './hooks/useFieldEditor.js';
5+
import './FieldEditor.css';
6+
import {FieldProps} from './fields/Field.js';
7+
8+
export interface FieldEditorProps<ValueType = any> {
9+
className?: string;
10+
schema: schema.Schema;
11+
onChange?: (value: ValueType) => void;
12+
value?: ValueType;
13+
}
14+
15+
/**
16+
* Component that renders an editor for fields from a schema.ts object.
17+
*/
18+
export function FieldEditor(props: FieldEditorProps) {
19+
const fields = (props.schema.fields || []).filter((field) => !!field.id);
20+
return (
21+
<FieldEditorProvider value={props.value} onChange={props.onChange}>
22+
<div className={joinClassNames(props.className, 'FieldEditor')}>
23+
{fields.map((field) => (
24+
<Field field={field} deepKey={field.id!} />
25+
))}
26+
</div>
27+
</FieldEditorProvider>
28+
);
29+
}
30+
31+
function Field(props: FieldProps) {
32+
const field = props.field;
33+
if (!field.id) {
34+
console.warn('missing id for field:');
35+
console.warn(field);
36+
return null;
37+
}
38+
39+
const showFieldHeader = !props.hideHeader;
40+
41+
return (
42+
<div className="FieldEditor__Field" data-deepkey={props.deepKey}>
43+
{showFieldHeader && (
44+
<FieldHeader {...props} />
45+
)}
46+
<div className="FieldEditor__Field__input">
47+
{field.type === 'string' ? (
48+
<StringField {...props} />
49+
) : (
50+
<div className="FieldEditor__Field__unknown">
51+
Unknown field type: {field.type}
52+
</div>
53+
)}
54+
</div>
55+
</div>
56+
);
57+
}
58+
59+
function FieldHeader(props: FieldProps & {className?: string}) {
60+
const field = props.field;
61+
return (
62+
<div className={joinClassNames(props.className, 'FieldEditor__FieldHeader')}>
63+
{field.deprecated ? (
64+
<div className="FieldEditor__FieldHeader__label">
65+
DEPRECATED: {field.label}
66+
</div>
67+
) : (
68+
<div className="FieldEditor__FieldHeader__label">
69+
<span>{field.label}</span>
70+
</div>
71+
)}
72+
{field.help && (
73+
<div className="FieldEditor__FieldHeader__help">{field.help}</div>
74+
)}
75+
</div>
76+
);
77+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import * as schema from '../../../../core/schema.js';
2+
3+
export interface FieldProps {
4+
deepKey: string;
5+
field: schema.Field;
6+
hideHeader?: boolean;
7+
}

packages/root-cms/ui/components/FieldEditor/fields/StringField.css

Whitespace-only changes.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {TextInput, Textarea} from '@mantine/core';
2+
import {ChangeEvent} from 'preact/compat';
3+
import {useEffect, useState} from 'preact/hooks';
4+
import * as schema from '../../../../core/schema.js';
5+
import {FieldProps} from './Field.js';
6+
import {useFieldEditor} from '../hooks/useFieldEditor.js';
7+
8+
export function StringField(props: FieldProps) {
9+
const field = props.field as schema.StringField;
10+
const [value, setValue] = useState('');
11+
const fieldEditor = useFieldEditor();
12+
13+
function onChange(newValue: string) {
14+
setValue(newValue);
15+
fieldEditor.set(props.deepKey, newValue);
16+
}
17+
18+
useEffect(() => {
19+
const unsubscribe = fieldEditor.subscribe(
20+
props.deepKey,
21+
(newValue: string) => {
22+
setValue(newValue);
23+
}
24+
);
25+
return unsubscribe;
26+
}, []);
27+
28+
if (field.variant === 'textarea') {
29+
return (
30+
<Textarea
31+
size="xs"
32+
radius={0}
33+
autosize
34+
minRows={2}
35+
maxRows={field.maxRows || 12}
36+
value={value}
37+
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
38+
onChange(e.currentTarget.value);
39+
}}
40+
/>
41+
);
42+
}
43+
return (
44+
<TextInput
45+
size="xs"
46+
radius={0}
47+
value={value}
48+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
49+
onChange(e.currentTarget.value);
50+
}}
51+
/>
52+
);
53+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {ComponentChildren, createContext} from 'preact';
2+
import {useContext, useMemo} from 'preact/hooks';
3+
import {setDeepKey} from '../../../../shared/objects.js';
4+
5+
export class FieldEditorController {
6+
value: any;
7+
onChange?: (newValue: any) => void;
8+
9+
constructor(value?: any, onChange?: (newValue: any) => void) {
10+
this.value = value || {};
11+
this.onChange = onChange;
12+
}
13+
14+
/**
15+
* Subscribes to changes on a deepKey.
16+
*/
17+
subscribe(deepKey: string, cb: (newValue: any) => void) {
18+
}
19+
20+
/**
21+
* Sets a value to a deepKey.
22+
*/
23+
set(deepKey: string, value: any) {
24+
setDeepKey(this.value, deepKey, value);
25+
if (this.onChange) {
26+
this.onChange(this.value);
27+
}
28+
}
29+
}
30+
31+
const CONTEXT = createContext<FieldEditorController | null>(null);
32+
33+
export interface FieldEditorProviderProps {
34+
value?: any;
35+
onChange?: (newValue: any) => void;
36+
children?: ComponentChildren;
37+
}
38+
39+
export function FieldEditorProvider(props: FieldEditorProviderProps) {
40+
const fieldEditor = useMemo(() => {
41+
return new FieldEditorController(props.value, props.onChange);
42+
}, []);
43+
return (
44+
<CONTEXT.Provider value={fieldEditor}>
45+
{props.children}
46+
</CONTEXT.Provider>
47+
);
48+
}
49+
50+
export function useFieldEditor(): FieldEditorController {
51+
const fieldEditor = useContext(CONTEXT);
52+
if (!fieldEditor) {
53+
throw new Error('useFieldEditor() should be used within a <FieldEditorProvider>');
54+
}
55+
return fieldEditor;
56+
}

packages/root-cms/ui/components/RichTextEditor/LexicalTheme.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
}
146146
.LexicalTheme__link {
147147
color: rgb(33, 111, 219);
148+
font-weight: 500;
148149
text-decoration: none;
149150
}
150151
.LexicalTheme__link:hover {

packages/root-cms/ui/components/RichTextEditor/LexicalTheme.tsx

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ export const LexicalTheme: EditorThemeClasses = {
5353
},
5454
hr: 'LexicalTheme__hr',
5555
hrSelected: 'LexicalTheme__hrSelected',
56-
image: 'editor-image',
56+
htmlCode: 'LexicalTheme__htmlCode',
57+
image: 'LexicalTheme__image',
5758
indent: 'LexicalTheme__indent',
58-
inlineImage: 'inline-editor-image',
5959
layoutContainer: 'LexicalTheme__layoutContainer',
6060
layoutItem: 'LexicalTheme__layoutItem',
6161
link: 'LexicalTheme__link',
@@ -85,26 +85,6 @@ export const LexicalTheme: EditorThemeClasses = {
8585
rtl: 'LexicalTheme__rtl',
8686
specialText: 'LexicalTheme__specialText',
8787
tab: 'LexicalTheme__tabNode',
88-
table: 'LexicalTheme__table',
89-
tableAddColumns: 'LexicalTheme__tableAddColumns',
90-
tableAddRows: 'LexicalTheme__tableAddRows',
91-
tableAlignment: {
92-
center: 'LexicalTheme__tableAlignmentCenter',
93-
right: 'LexicalTheme__tableAlignmentRight',
94-
},
95-
tableCell: 'LexicalTheme__tableCell',
96-
tableCellActionButton: 'LexicalTheme__tableCellActionButton',
97-
tableCellActionButtonContainer:
98-
'LexicalTheme__tableCellActionButtonContainer',
99-
tableCellHeader: 'LexicalTheme__tableCellHeader',
100-
tableCellResizer: 'LexicalTheme__tableCellResizer',
101-
tableCellSelected: 'LexicalTheme__tableCellSelected',
102-
tableFrozenColumn: 'LexicalTheme__tableFrozenColumn',
103-
tableFrozenRow: 'LexicalTheme__tableFrozenRow',
104-
tableRowStriping: 'LexicalTheme__tableRowStriping',
105-
tableScrollableWrapper: 'LexicalTheme__tableScrollableWrapper',
106-
tableSelected: 'LexicalTheme__tableSelected',
107-
tableSelection: 'LexicalTheme__tableSelection',
10888
text: {
10989
bold: 'LexicalTheme__textBold',
11090
capitalize: 'LexicalTheme__textCapitalize',
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.EmbedModal__buttons {
2+
margin-top: 24px;
3+
display: flex;
4+
align-items: center;
5+
justify-content: flex-end;
6+
gap: 12px;
7+
}

0 commit comments

Comments
 (0)