feat: ui overhaul (#676)

This commit is contained in:
Daniel Lautzenheiser
2023-03-30 13:29:09 -04:00
committed by GitHub
parent 5c86462859
commit 66b81e9228
385 changed files with 20607 additions and 16493 deletions

View File

@ -1,7 +1,7 @@
import { red } from '@mui/material/colors';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import Field from '@staticcms/core/components/common/field/Field';
import Switch from '@staticcms/core/components/common/switch/Switch';
import type { BooleanField, WidgetControlProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC } from 'react';
@ -9,15 +9,19 @@ import type { ChangeEvent, FC } from 'react';
const BooleanControl: FC<WidgetControlProps<boolean, BooleanField>> = ({
value,
label,
isDuplicate,
errors,
disabled,
field,
forSingleList,
duplicate,
onChange,
hasErrors,
}) => {
const [internalRawValue, setInternalValue] = useState(value);
const [internalRawValue, setInternalValue] = useState(value ?? false);
const internalValue = useMemo(
() => (isDuplicate ? value : internalRawValue),
[internalRawValue, isDuplicate, value],
() => (duplicate ? value ?? false : internalRawValue),
[internalRawValue, duplicate, value],
);
const ref = useRef<HTMLInputElement | null>(null);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
@ -28,15 +32,18 @@ const BooleanControl: FC<WidgetControlProps<boolean, BooleanField>> = ({
);
return (
<FormControlLabel
key="boolean-field-label"
control={
<Switch key="boolean-input" checked={internalValue ?? false} onChange={handleChange} />
}
<Field
inputRef={ref}
label={label}
labelPlacement="start"
sx={{ marginLeft: '4px', color: hasErrors ? red[500] : undefined }}
/>
errors={errors}
variant="inline"
cursor="pointer"
hint={field.hint}
forSingleList={forSingleList}
disabled={disabled}
>
<Switch ref={ref} value={internalValue} disabled={disabled} onChange={handleChange} />
</Field>
);
};

View File

@ -0,0 +1,139 @@
/**
* @jest-environment jsdom
*/
import '@testing-library/jest-dom';
import { act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { mockBooleanField } from '@staticcms/test/data/fields.mock';
import { createWidgetControlHarness } from '@staticcms/test/harnesses/widget.harness';
import BooleanControl from '../BooleanControl';
describe(BooleanControl.name, () => {
const renderControl = createWidgetControlHarness(BooleanControl, { field: mockBooleanField });
it('should render', () => {
const { getByTestId } = renderControl({ label: 'I am a label' });
expect(getByTestId('switch-input')).toBeInTheDocument();
const label = getByTestId('label');
expect(label.textContent).toBe('I am a label');
expect(label).toHaveClass('text-slate-500');
const field = getByTestId('inline-field');
expect(field).toHaveClass('group/active');
const fieldWrapper = getByTestId('inline-field-wrapper');
expect(fieldWrapper).not.toHaveClass('mr-14');
// Boolean Widget uses pointer cursor
expect(label).toHaveClass('cursor-pointer');
expect(field).toHaveClass('cursor-pointer');
// Boolean Widget uses inline label layout, with bottom padding on field
expect(label).not.toHaveClass('px-3', 'pt-3');
expect(field).toHaveClass('pb-3');
});
it('should render as single list item', () => {
const { getByTestId } = renderControl({ label: 'I am a label', forSingleList: true });
expect(getByTestId('switch-input')).toBeInTheDocument();
const fieldWrapper = getByTestId('inline-field-wrapper');
expect(fieldWrapper).toHaveClass('mr-14');
});
it('should only use prop value as initial value', async () => {
const { rerender, getByTestId } = renderControl({ value: true });
const input = getByTestId('switch-input');
expect(input).toBeChecked();
rerender({ value: false });
expect(input).toBeChecked();
});
it('should use prop value exclusively if field is i18n duplicate', async () => {
const { rerender, getByTestId } = renderControl({
field: { ...mockBooleanField, i18n: 'duplicate' },
duplicate: true,
value: true,
});
const input = getByTestId('switch-input');
expect(input).toBeChecked();
rerender({ value: false });
expect(input).not.toBeChecked();
});
it('should call onChange when switch is clicked', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl();
const input = getByTestId('switch-input');
expect(onChange).not.toHaveBeenCalled();
await act(async () => {
await userEvent.click(input);
});
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenLastCalledWith(true);
await act(async () => {
await userEvent.click(input);
});
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith(false);
});
it('should show error', async () => {
const { getByTestId } = renderControl({
errors: [{ type: 'error-type', message: 'i am an error' }],
});
const error = getByTestId('error');
expect(error.textContent).toBe('i am an error');
const field = getByTestId('inline-field');
expect(field).not.toHaveClass('group/active');
const label = getByTestId('label');
expect(label).toHaveClass('text-red-500');
});
it('should focus input on field click', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl();
const input = getByTestId('switch-input');
expect(input).not.toHaveFocus();
expect(onChange).not.toHaveBeenCalled();
const field = getByTestId('inline-field');
await act(async () => {
await userEvent.click(field);
});
expect(input).toHaveFocus();
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenLastCalledWith(true);
});
it('should disable input if disabled', async () => {
const { getByTestId } = await renderControl({ disabled: true });
const input = getByTestId('switch-input');
expect(input).toBeDisabled();
});
});

View File

@ -1,13 +1,17 @@
import { styled } from '@mui/material/styles';
import Collapse from '@mui/material/Collapse';
import { ChevronRight as ChevronRightIcon } from '@styled-icons/material/ChevronRight';
import { loadLanguage } from '@uiw/codemirror-extensions-langs';
import CodeMirror from '@uiw/react-codemirror';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import ErrorMessage from '@staticcms/core/components/common/field/ErrorMessage';
import Hint from '@staticcms/core/components/common/field/Hint';
import Label from '@staticcms/core/components/common/field/Label';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import transientOptions from '@staticcms/core/lib/util/transientOptions';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
import languages from './data/languages';
import SettingsButton from './SettingsButton';
import SettingsPane from './SettingsPane';
@ -18,35 +22,7 @@ import type {
WidgetControlProps,
} from '@staticcms/core/interface';
import type { LanguageName } from '@uiw/codemirror-extensions-langs';
import type { FC } from 'react';
const StyledCodeControlWrapper = styled('div')`
display: flex;
flex-direction: column;
position: relative;
width: 100%;
`;
interface StyledCodeControlContentProps {
$collapsed: boolean;
}
const StyledCodeControlContent = styled(
'div',
transientOptions,
)<StyledCodeControlContentProps>(
({ $collapsed }) => `
display: block;
width: 100%;
${
$collapsed
? `
display: none;
`
: ''
}
`,
);
import type { FC, MouseEvent } from 'react';
function valueToOption(val: string | { name: string; label?: string }): {
value: string;
@ -59,13 +35,18 @@ function valueToOption(val: string | { name: string; label?: string }): {
}
const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, CodeField>> = ({
label,
field,
isDuplicate,
duplicate,
onChange,
hasErrors,
value,
t,
forSingleList,
errors,
disabled,
}) => {
const theme = useAppSelector(selectTheme);
const keys = useMemo(() => {
const defaults = {
code: 'code',
@ -80,25 +61,28 @@ const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, Cod
const [internalRawValue, setInternalValue] = useState(value ?? '');
const internalValue = useMemo(
() => (isDuplicate ? value ?? '' : internalRawValue),
[internalRawValue, isDuplicate, value],
() => (duplicate ? value ?? '' : internalRawValue),
[internalRawValue, duplicate, value],
);
const [lang, setLang] = useState<ProcessedCodeLanguage | null>(null);
const [collapsed, setCollapsed] = useState(false);
const [hasFocus, setHasFocus] = useState(false);
const handleFocus = useCallback(() => {
setHasFocus(true);
const [settingsVisible, setSettingsVisible] = useState(false);
const toggleSettings = useCallback((event: MouseEvent) => {
event.stopPropagation();
setSettingsVisible(old => !old);
}, []);
const handleBlur = useCallback(() => {
setHasFocus(false);
const hideSettings = useCallback(() => {
setSettingsVisible(false);
}, []);
const handleCollapseToggle = useCallback(() => {
setCollapsed(!collapsed);
}, [collapsed]);
const [open, setOpen] = useState(true);
const handleOpenToggle = useCallback(() => {
setOpen(oldOpen => !oldOpen);
setSettingsVisible(false);
}, []);
const handleOnChange = useCallback(
(newValue: string | { [key: string]: string } | null | undefined) => {
@ -144,15 +128,6 @@ const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, Cod
return internalValue[keys.code];
}, [internalValue, keys.code]);
const [settingsVisible, setSettingsVisible] = useState(false);
const showSettings = useCallback(() => {
setSettingsVisible(true);
}, []);
const hideSettings = useCallback(() => {
setSettingsVisible(false);
}, []);
const uniqueId = useUUID();
// If `allow_language_selection` is not set, default to true. Otherwise, use its value.
@ -163,8 +138,8 @@ const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, Cod
const availableLanguages = languages.map(language => valueToOption(language.label));
const handleSetLanguage = useCallback((langIdentifier: string) => {
const language = languages.find(language => language.identifiers.includes(langIdentifier));
const handleSetLanguage = useCallback((langLabel: string) => {
const language = languages.find(language => language.label === langLabel);
if (language) {
setLang(language);
}
@ -186,44 +161,127 @@ const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, Cod
}, [field.default_language, handleSetLanguage, internalValue, keys.lang, valueIsMap]);
return (
<StyledCodeControlWrapper>
{allowLanguageSelection ? (
!settingsVisible ? (
<SettingsButton onClick={showSettings} />
) : (
<div
data-testid="list-field"
className={classNames(
`
relative
flex
flex-col
border-b
border-slate-400
focus-within:border-blue-800
dark:focus-within:border-blue-100
`,
!hasErrors && 'group/active-list',
)}
>
<div
data-testid="field-wrapper"
className={classNames(
`
relative
flex
flex-col
w-full
`,
forSingleList && 'mr-14',
)}
>
<button
data-testid="list-expand-button"
className={classNames(
`
flex
w-full
justify-between
px-3
py-2
text-left
text-sm
font-medium
focus:outline-none
focus-visible:ring
gap-2
focus-visible:ring-opacity-75
items-center
`,
disabled && 'cursor-default',
)}
onClick={handleOpenToggle}
>
<Label
key="label"
hasErrors={hasErrors}
className={classNames(
!disabled &&
`
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500
`,
)}
cursor="pointer"
variant="inline"
disabled={disabled}
>
{label}
</Label>
{open && allowLanguageSelection ? (
<SettingsButton onClick={toggleSettings} disabled={disabled} />
) : null}
<ChevronRightIcon
className={classNames(
open && 'rotate-90 transform',
`
transition-transform
h-5
w-5
`,
disabled
? `
text-slate-300
dark:text-slate-600
`
: `
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500
`,
)}
/>
</button>
{open && allowLanguageSelection && settingsVisible ? (
<SettingsPane
hideSettings={hideSettings}
uniqueId={uniqueId}
languages={availableLanguages}
language={valueToOption(lang?.label ?? '')}
allowLanguageSelection={allowLanguageSelection}
onChangeLanguage={handleSetLanguage}
hideSettings={hideSettings}
/>
)
) : null}
<ObjectWidgetTopBar
key="file-control-top-bar"
collapsed={collapsed}
onCollapseToggle={handleCollapseToggle}
heading={field.label ?? field.name}
hasError={hasErrors}
t={t}
/>
<StyledCodeControlContent $collapsed={collapsed}>
<CodeMirror
value={code}
height="auto"
minHeight="120px"
width="100%"
editable={true}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleChange}
extensions={extensions}
/>
</StyledCodeControlContent>
<Outline active={hasFocus} hasError={hasErrors} />
</StyledCodeControlWrapper>
) : null}
<Collapse in={open} appear={false}>
<div>
<CodeMirror
value={code}
height="auto"
minHeight="120px"
width="100%"
editable={true}
onChange={handleChange}
extensions={extensions}
theme={theme}
readOnly={disabled}
/>
</div>
</Collapse>
{field.hint ? (
<Hint key="hint" hasErrors={hasErrors} cursor="pointer" disabled={disabled}>
{field.hint}
</Hint>
) : null}
<ErrorMessage errors={errors} />
</div>
</div>
);
};

View File

@ -1,8 +1,6 @@
import isString from 'lodash/isString';
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import type { CodeField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
@ -23,11 +21,9 @@ const CodePreview: FC<WidgetPreviewProps<string | Record<string, string>, CodeFi
field,
}) => {
return (
<WidgetPreviewContainer>
<pre>
<code>{toValue(value, field)}</code>
</pre>
</WidgetPreviewContainer>
<pre>
<code>{toValue(value, field)}</code>
</pre>
);
};

View File

@ -1,35 +1,22 @@
import CloseIcon from '@mui/icons-material/Close';
import SettingsIcon from '@mui/icons-material/Settings';
import IconButton from '@mui/material/IconButton';
import { styled } from '@mui/material/styles';
import { Close as CloseIcon } from '@styled-icons/material/Close';
import { Settings as SettingsIcon } from '@styled-icons/material/Settings';
import React from 'react';
import { zIndex } from '@staticcms/core/components/UI/styles';
import IconButton from '@staticcms/core/components/common/button/IconButton';
import type { FC, MouseEvent } from 'react';
const StyledSettingsButton = styled(IconButton)`
position: absolute;
z-index: ${zIndex.zIndex100};
right: 8px;
top: 8px;
opacity: 0.8;
padding: 2px 4px;
line-height: 1;
height: auto;
color: #000;
`;
export interface SettingsButtonProps {
showClose?: boolean;
disabled: boolean;
onClick: (event: MouseEvent) => void;
}
const SettingsButton: FC<SettingsButtonProps> = ({ showClose = false, onClick }) => {
const SettingsButton: FC<SettingsButtonProps> = ({ showClose = false, disabled, onClick }) => {
return (
<StyledSettingsButton onClick={onClick}>
{showClose ? <CloseIcon /> : <SettingsIcon />}
</StyledSettingsButton>
<IconButton onClick={onClick} size="small" variant="text" disabled={disabled}>
{showClose ? <CloseIcon className="w-5 h-5" /> : <SettingsIcon className="w-5 h-5" />}
</IconButton>
);
};

View File

@ -1,47 +1,15 @@
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import { styled } from '@mui/material/styles';
import isHotkey from 'is-hotkey';
import React from 'react';
import { shadows, zIndex } from '@staticcms/core/components/UI/styles';
import SettingsButton from './SettingsButton';
import Label from '@staticcms/core/components/common/field/Label';
import Select from '@staticcms/core/components/common/select/Select';
import type { SelectChangeEvent } from '@mui/material/Select';
import type { FC } from 'react';
const SettingsPaneContainer = styled('div')`
position: absolute;
top: 1px;
bottom: 1px;
right: 1px;
width: 200px;
z-index: ${zIndex.zIndex10};
background-color: #fff;
overflow: hidden;
padding: 12px;
border-radius: 0 3px 3px 0;
${shadows.drop};
display: flex;
flex-direction: column;
gap: 16px;
`;
const SettingsSectionTitle = styled('h3')`
font-size: 14px;
margin-top: 14px;
margin-bottom: 0;
&:first-of-type {
margin-top: 4px;
}
`;
interface SettingsSelectProps {
type: 'language';
label: string;
placeholder?: string;
uniqueId: string;
value: {
value: string;
@ -57,34 +25,31 @@ interface SettingsSelectProps {
const SettingsSelect: FC<SettingsSelectProps> = ({
value,
label,
placeholder,
options,
onChange,
uniqueId,
type,
}) => {
const handleChange = (event: SelectChangeEvent<string>) => {
onChange(event.target.value);
const handleChange = (newValue: string | number | (string | number)[]) => {
if (typeof newValue === 'string') {
onChange(newValue);
}
};
return (
<FormControl fullWidth size="small">
<InputLabel id={`${uniqueId}-select-${type}-label`}>{label}</InputLabel>
<div>
<Label htmlFor={`${uniqueId}-select-${type}-label`} disabled={false}>
{label}
</Label>
<Select
labelId={`${uniqueId}-select-${type}-label`}
id={`${uniqueId}-select-${type}`}
value={value.value}
label={label}
label={value.value}
placeholder={placeholder}
options={options}
onChange={handleChange}
>
{options.map(({ label, value }) =>
value ? (
<MenuItem key={`${uniqueId}-select-${type}-option-${value}`} value={value}>
{label}
</MenuItem>
) : null,
)}
</Select>
</FormControl>
/>
</div>
);
};
@ -111,20 +76,38 @@ const SettingsPane: FC<SettingsPaneProps> = ({
onChangeLanguage,
}) => {
return (
<SettingsPaneContainer onKeyDown={e => isHotkey('esc', e) && hideSettings()}>
<SettingsButton onClick={hideSettings} showClose={true} />
<>
<SettingsSectionTitle>Field Settings</SettingsSectionTitle>
<SettingsSelect
type="language"
label="Language"
uniqueId={uniqueId}
value={language}
options={languages}
onChange={onChangeLanguage}
/>
</>
</SettingsPaneContainer>
<div
onKeyDown={e => isHotkey('esc', e) && hideSettings()}
className="
absolute
top-10
bottom-0
right-0
w-40
flex
flex-col
gap-2
z-10
shadow-sm
bg-gray-100
dark:bg-slate-800
border-l
border-l-slate-400
border-t
border-t-slate-300
dark:border-t-slate-700
"
>
<SettingsSelect
type="language"
label="Language"
placeholder="Select language"
uniqueId={uniqueId}
value={language}
options={languages}
onChange={onChangeLanguage}
/>
</div>
);
};

View File

@ -1,38 +0,0 @@
import { reactSelectStyles, borders } from '@staticcms/core/components/UI/styles';
import type { CSSProperties } from 'react';
import type { OptionStyleState } from '@staticcms/core/components/UI/styles';
const languageSelectStyles = {
...reactSelectStyles,
container: (provided: CSSProperties) => ({
...reactSelectStyles.container(provided),
'margin-top': '2px',
}),
control: (provided: CSSProperties) => ({
...reactSelectStyles.control(provided),
border: borders.textField,
padding: 0,
fontSize: '13px',
minHeight: 'auto',
}),
dropdownIndicator: (provided: CSSProperties) => ({
...reactSelectStyles.dropdownIndicator(provided),
padding: '4px',
}),
option: (provided: CSSProperties, state: OptionStyleState) => ({
...reactSelectStyles.option(provided, state),
padding: 0,
paddingLeft: '8px',
}),
menu: (provided: CSSProperties) => ({
...reactSelectStyles.menu(provided),
margin: '2px 0',
}),
menuList: (provided: CSSProperties) => ({
...provided,
'max-height': '200px',
}),
};
export default languageSelectStyles;

View File

@ -1,126 +1,35 @@
import CloseIcon from '@mui/icons-material/Close';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import { styled } from '@mui/material/styles';
import TextField from '@mui/material/TextField';
import React, { useCallback, useMemo, useState } from 'react';
import { Close as CloseIcon } from '@styled-icons/material/Close';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { ChromePicker } from 'react-color';
import validateColor from 'validate-color';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import { zIndex } from '@staticcms/core/components/UI/styles';
import { transientOptions } from '@staticcms/core/lib';
import IconButton from '@staticcms/core/components/common/button/IconButton';
import Field from '@staticcms/core/components/common/field/Field';
import TextField from '@staticcms/core/components/common/text-field/TextField';
import classNames from '@staticcms/core/lib/util/classNames.util';
import type { ColorField, WidgetControlProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC, MouseEvent } from 'react';
import type { ColorResult } from 'react-color';
const StyledColorControlWrapper = styled('div')`
display: flex;
flex-direction: column;
position: relative;
width: 100%;
`;
interface StyledColorControlContentProps {
$collapsed: boolean;
}
const StyledColorControlContent = styled(
'div',
transientOptions,
)<StyledColorControlContentProps>(
({ $collapsed }) => `
display: flex;
${
$collapsed
? `
display: none;
`
: `
padding: 16px;
`
}
`,
);
// color swatch background with checkerboard to display behind transparent colors
const ColorSwatchBackground = styled('div')`
position: absolute;
z-index: ${zIndex.zIndex1};
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==');
height: 38px;
width: 48px;
margin-top: 10px;
margin-left: 10px;
border-radius: 5px;
`;
interface ColorSwatchProps {
$background: string;
$color: string;
}
const ColorSwatch = styled(
'div',
transientOptions,
)<ColorSwatchProps>(
({ $background, $color }) => `
position: absolute;
z-index: ${zIndex.zIndex2};
background: ${$background};
cursor: pointer;
height: 38px;
width: 48px;
margin-top: 10px;
margin-left: 10px;
border-radius: 5px;
border: 2px solid rgb(223, 223, 227);
text-align: center;
font-size: 27px;
line-height: 1;
padding-top: 4px;
user-select: none;
color: ${$color};
`,
);
const ColorPickerContainer = styled('div')`
position: absolute;
z-index: ${zIndex.zIndex1000};
margin-top: 48px;
margin-left: 12px;
`;
// fullscreen div to close color picker when clicking outside of picker
const ClickOutsideDiv = styled('div')`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
`;
const ColorControl: FC<WidgetControlProps<string, ColorField>> = ({
field,
isDuplicate,
duplicate,
onChange,
value,
hasErrors,
t,
errors,
label,
forSingleList,
disabled,
}) => {
const [collapsed, setCollapsed] = useState(false);
const handleCollapseToggle = useCallback(() => {
setCollapsed(!collapsed);
}, [collapsed]);
const swatchRef = useRef<HTMLDivElement | null>(null);
const ref = useRef<HTMLInputElement | null>(null);
const [showColorPicker, setShowColorPicker] = useState(false);
const [internalRawValue, setInternalValue] = useState(value ?? '');
const internalValue = useMemo(
() => (isDuplicate ? value ?? '' : internalRawValue),
[internalRawValue, isDuplicate, value],
() => (duplicate ? value ?? '' : internalRawValue),
[internalRawValue, duplicate, value],
);
// show/hide color picker
@ -167,63 +76,97 @@ const ColorControl: FC<WidgetControlProps<string, ColorField>> = ({
const showClearButton = !allowInput && internalValue;
return (
<StyledColorControlWrapper>
<ObjectWidgetTopBar
key="file-control-top-bar"
collapsed={collapsed}
onCollapseToggle={handleCollapseToggle}
heading={field.label ?? field.name}
hasError={hasErrors}
t={t}
/>
<StyledColorControlContent $collapsed={collapsed}>
<ColorSwatchBackground />
<ColorSwatch
key="color-swatch"
$background={validateColor(internalValue) ? internalValue : '#fff'}
$color={validateColor(internalValue) ? 'rgba(255, 255, 255, 0)' : 'rgb(223, 223, 227)'}
onClick={handleClick}
>
?
</ColorSwatch>
<Field
inputRef={allowInput ? ref : swatchRef}
label={label}
errors={errors}
hint={field.hint}
forSingleList={forSingleList}
cursor={allowInput ? 'text' : 'pointer'}
disabled={disabled}
>
<div
className={classNames(
`
flex
items-center
pt-2
px-3
`,
disabled ? 'cursor-default' : allowInput ? 'cursor-text' : 'cursor-pointer',
)}
>
<div>
<div
ref={swatchRef}
key="color-swatch"
onClick={!disabled ? handleClick : undefined}
style={{
background: validateColor(internalValue) ? internalValue : '#fff',
color: validateColor(internalValue) ? 'rgba(255, 255, 255, 0)' : 'rgb(150, 150, 150)',
}}
className={classNames(
`
w-8
h-8
flex
items-center
justify-center
`,
disabled ? 'cursor-default' : 'cursor-pointer',
)}
>
?
</div>
</div>
{showColorPicker && (
<ColorPickerContainer key="color-swatch-wrapper">
<ClickOutsideDiv key="click-outside" onClick={handleClose} />
<div
key="color-swatch-wrapper"
className="
absolute
bottom-0
"
>
<div
key="click-outside"
onClick={handleClose}
className="
fixed
inset-0
z-10
"
/>
<ChromePicker
key="color-picker"
color={internalValue}
onChange={handlePickerChange}
disableAlpha={!(field.enable_alpha ?? false)}
className="
absolute
z-20
-top-3
"
/>
</ColorPickerContainer>
</div>
)}
<TextField
type="text"
inputRef={ref}
key="color-picker-input"
value={internalValue}
onChange={handleInputChange}
sx={{
color: !allowInput ? '#bbb' : undefined,
'.MuiInputBase-input': {
paddingLeft: '75px',
},
}}
// make readonly and open color picker on click if set to allow_input: false
onClick={!allowInput ? handleClick : undefined}
disabled={!allowInput}
fullWidth
InputProps={{
endAdornment: showClearButton ? (
<InputAdornment position="start">
<IconButton onClick={handleClear} aria-label="clear">
<CloseIcon />
</IconButton>
</InputAdornment>
) : undefined,
}}
onClick={!allowInput && !disabled ? handleClick : undefined}
disabled={!allowInput || disabled}
cursor={allowInput ? 'text' : 'pointer'}
/>
</StyledColorControlContent>
<Outline hasError={hasErrors} />
</StyledColorControlWrapper>
{showClearButton ? (
<IconButton variant="text" onClick={handleClear} disabled={disabled}>
<CloseIcon className="w-5 h-5" />
</IconButton>
) : null}
</div>
</Field>
);
};

View File

@ -1,12 +1,10 @@
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import type { ColorField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const ColorPreview: FC<WidgetPreviewProps<string, ColorField>> = ({ value }) => {
return <WidgetPreviewContainer>{value}</WidgetPreviewContainer>;
return <div>{value}</div>;
};
export default ColorPreview;

View File

@ -1,97 +1,98 @@
import TodayIcon from '@mui/icons-material/Today';
import Button from '@mui/material/Button';
import { styled } from '@mui/material/styles';
import TextField from '@mui/material/TextField';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { MobileDatePicker } from '@mui/x-date-pickers/MobileDatePicker';
import { MobileDateTimePicker } from '@mui/x-date-pickers/MobileDateTimePicker';
import { TimePicker } from '@mui/x-date-pickers/TimePicker';
import { MobileTimePicker } from '@mui/x-date-pickers/MobileTimePicker';
import formatDate from 'date-fns/format';
import formatISO from 'date-fns/formatISO';
import parse from 'date-fns/parse';
import parseISO from 'date-fns/parseISO';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import Field from '@staticcms/core/components/common/field/Field';
import TextField from '@staticcms/core/components/common/text-field/TextField';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import NowButton from './components/NowButton';
import { DEFAULT_DATETIME_FORMAT, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from './constants';
import { localToUTC } from './utc.util';
import type { DateTimeField, TranslatedProps, WidgetControlProps } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
import type { TextFieldProps as MuiTextFieldProps } from '@mui/material/TextField';
import type { TextFieldProps } from '@staticcms/core/components/common/text-field/TextField';
import type { DateTimeField, WidgetControlProps } from '@staticcms/core/interface';
import type { FC } from 'react';
export function localToUTC(dateTime: Date, timezoneOffset: number) {
const utcFromLocal = new Date(dateTime.getTime() - timezoneOffset);
return utcFromLocal;
function convertMuiTextFieldProps({
inputProps,
disabled,
onClick,
}: MuiTextFieldProps): TextFieldProps {
const value: string = inputProps?.value ?? '';
return {
type: 'text',
value,
disabled,
// eslint-disable-next-line @typescript-eslint/no-empty-function
onChange: () => {},
onClick,
};
}
const StyledNowButton = styled('div')`
width: fit-content;
`;
interface NowButtonProps {
handleChange: (value: Date) => void;
disabled: boolean;
}
const NowButton: FC<TranslatedProps<NowButtonProps>> = ({ t, handleChange, disabled }) => {
const handleClick = useCallback(
(event: MouseEvent) => {
event.stopPropagation();
handleChange(new Date());
},
[handleChange],
);
return (
<StyledNowButton key="now-button-wrapper">
<Button
key="now-button"
onClick={handleClick}
disabled={disabled}
startIcon={<TodayIcon key="today-icon" />}
variant="outlined"
>
{t('editor.editorWidgets.datetime.now')}
</Button>
</StyledNowButton>
);
};
const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
field,
label,
value,
disabled,
duplicate,
errors,
forSingleList,
t,
isDisabled,
isDuplicate,
onChange,
hasErrors,
}) => {
const { format, dateFormat, timeFormat } = useMemo(() => {
const format = field.format;
const ref = useRef<HTMLInputElement | null>(null);
const [open, setOpen] = useState(false);
const handleOpen = useCallback(() => {
setOpen(true);
}, []);
const handleClose = useCallback(() => {
setOpen(false);
}, []);
const { format, dateFormat, timeFormat } = useMemo(() => {
// dateFormat and timeFormat are strictly for modifying input field with the date/time pickers
const dateFormat: string | boolean = field.date_format ?? true;
// show time-picker? false hides it, true shows it using default format
const timeFormat: string | boolean = field.time_format ?? true;
let finalFormat = field.format;
if (timeFormat === false) {
finalFormat = field.format ?? DEFAULT_DATE_FORMAT;
} else if (dateFormat === false) {
finalFormat = field.format ?? DEFAULT_TIME_FORMAT;
} else {
finalFormat = field.format ?? DEFAULT_DATETIME_FORMAT;
}
return {
format,
format: finalFormat,
dateFormat,
timeFormat,
};
}, [field.date_format, field.format, field.time_format]);
const timezoneOffset = useMemo(() => new Date().getTimezoneOffset() * 60000, []);
const inputFormat = useMemo(() => {
if (typeof dateFormat === 'string' || typeof timeFormat === 'string') {
const formatParts: string[] = [];
if (typeof dateFormat === 'string' && isNotEmpty(dateFormat)) {
formatParts.push(dateFormat);
} else if (dateFormat !== false) {
formatParts.push(DEFAULT_DATE_FORMAT);
}
if (typeof timeFormat === 'string' && isNotEmpty(timeFormat)) {
formatParts.push(timeFormat);
} else if (timeFormat !== false) {
formatParts.push(DEFAULT_TIME_FORMAT);
}
if (formatParts.length > 0) {
@ -100,29 +101,29 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
}
if (timeFormat === false) {
return 'yyyy-MM-dd';
return format ?? DEFAULT_DATE_FORMAT;
}
if (dateFormat === false) {
return 'HH:mm:ss.SSSXXX';
return format ?? DEFAULT_TIME_FORMAT;
}
return "yyyy-MM-dd'T'HH:mm:ss.SSSXXX";
}, [dateFormat, timeFormat]);
return format ?? DEFAULT_DATETIME_FORMAT;
}, [dateFormat, format, timeFormat]);
const defaultValue = useMemo(() => {
const today = field.picker_utc ? localToUTC(new Date(), timezoneOffset) : new Date();
const today = field.picker_utc ? localToUTC(new Date()) : new Date();
return field.default === undefined
? format
? formatDate(today, format)
: formatDate(today, inputFormat)
: formatDate(today, DEFAULT_DATETIME_FORMAT)
: field.default;
}, [field.default, field.picker_utc, format, inputFormat, timezoneOffset]);
}, [field.default, field.picker_utc, format]);
const [internalRawValue, setInternalValue] = useState(value);
const internalValue = useMemo(
() => (isDuplicate ? value : internalRawValue),
[internalRawValue, isDuplicate, value],
() => (duplicate ? value : internalRawValue),
[internalRawValue, duplicate, value],
);
const dateValue: Date = useMemo(() => {
@ -134,12 +135,6 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
return format ? parse(valueToParse, format, new Date()) : parseISO(valueToParse);
}, [defaultValue, format, internalValue]);
const utcDate = useMemo(() => {
const dateTime = new Date(dateValue);
const utcFromLocal = new Date(dateTime.getTime() + timezoneOffset) ?? defaultValue;
return utcFromLocal;
}, [dateValue, defaultValue, timezoneOffset]);
const handleChange = useCallback(
(datetime: Date | null) => {
if (datetime === null) {
@ -148,49 +143,43 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
return;
}
const adjustedValue = field.picker_utc ? localToUTC(datetime, timezoneOffset) : datetime;
const adjustedValue = field.picker_utc ? localToUTC(datetime) : datetime;
let formattedValue: string;
if (format) {
formattedValue = formatDate(adjustedValue, format);
} else {
formattedValue = formatISO(adjustedValue);
}
const formattedValue = formatDate(adjustedValue, format);
setInternalValue(formattedValue);
onChange(formattedValue);
},
[defaultValue, field.picker_utc, format, onChange, timezoneOffset],
[defaultValue, field.picker_utc, format, onChange],
);
const dateTimePicker = useMemo(() => {
const inputDate = field.picker_utc ? utcDate : dateValue;
if (dateFormat && !timeFormat) {
return (
<MobileDatePicker
key="mobile-date-picker"
inputFormat={inputFormat}
label={label}
value={inputDate}
value={dateValue}
disabled={disabled}
onChange={handleChange}
disabled={isDisabled}
renderInput={params => (
<TextField
key="mobile-date-input"
{...params}
error={hasErrors}
fullWidth
InputProps={{
endAdornment: (
<NowButton
key="mobile-date-now"
t={t}
handleChange={v => handleChange(v)}
disabled={isDisabled}
/>
),
}}
/>
onOpen={handleOpen}
onClose={handleClose}
renderInput={props => (
<>
<TextField
key="mobile-date-input"
data-testid="date-input"
{...convertMuiTextFieldProps(props)}
inputRef={ref}
cursor="pointer"
/>
<NowButton
key="mobile-date-now"
t={t}
handleChange={v => handleChange(v)}
disabled={disabled}
/>
</>
)}
/>
);
@ -198,30 +187,31 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
if (!dateFormat && timeFormat) {
return (
<TimePicker
<MobileTimePicker
key="time-picker"
label={label}
inputFormat={inputFormat}
value={inputDate}
value={dateValue}
disabled={disabled}
onChange={handleChange}
disabled={isDisabled}
renderInput={params => (
<TextField
key="time-input"
{...params}
error={hasErrors}
fullWidth
InputProps={{
endAdornment: (
<NowButton
key="time-now"
t={t}
handleChange={v => handleChange(v)}
disabled={isDisabled}
/>
),
}}
/>
onOpen={handleOpen}
onClose={handleClose}
renderInput={props => (
<>
<TextField
key="mobile-time-input"
data-testid="time-input"
{...convertMuiTextFieldProps(props)}
inputRef={ref}
cursor="pointer"
/>
<NowButton
key="mobile-date-now"
t={t}
handleChange={v => handleChange(v)}
disabled={disabled}
/>
</>
)}
/>
);
@ -232,47 +222,57 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
key="mobile-date-time-picker"
inputFormat={inputFormat}
label={label}
value={inputDate}
value={dateValue}
disabled={disabled}
onChange={handleChange}
disabled={isDisabled}
renderInput={params => (
<TextField
key="mobile-date-time-input"
{...params}
error={hasErrors}
fullWidth
InputProps={{
endAdornment: (
<NowButton
key="mobile-date-time-now"
t={t}
handleChange={v => handleChange(v)}
disabled={isDisabled}
/>
),
}}
/>
onOpen={handleOpen}
onClose={handleClose}
renderInput={props => (
<>
<TextField
key="mobile-date-time-input"
data-testid="date-time-input"
{...convertMuiTextFieldProps(props)}
inputRef={ref}
cursor="pointer"
/>
<NowButton
key="mobile-date-now"
t={t}
handleChange={v => handleChange(v)}
disabled={disabled}
/>
</>
)}
/>
);
}, [
dateFormat,
dateValue,
field.picker_utc,
handleChange,
hasErrors,
handleClose,
handleOpen,
inputFormat,
isDisabled,
disabled,
label,
t,
timeFormat,
utcDate,
]);
return (
<LocalizationProvider key="localization-provider" dateAdapter={AdapterDateFns}>
{dateTimePicker}
</LocalizationProvider>
<Field
inputRef={!open ? ref : undefined}
label={label}
errors={errors}
hint={field.hint}
forSingleList={forSingleList}
cursor="pointer"
disabled={disabled}
>
<LocalizationProvider key="localization-provider" dateAdapter={AdapterDateFns}>
{dateTimePicker}
</LocalizationProvider>
</Field>
);
};

View File

@ -1,12 +1,10 @@
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import type { DateTimeField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const DatePreview: FC<WidgetPreviewProps<string, DateTimeField>> = ({ value }) => {
return <WidgetPreviewContainer>{value ? value.toString() : null}</WidgetPreviewContainer>;
return <div>{value ? value.toString() : null}</div>;
};
export default DatePreview;

View File

@ -0,0 +1,993 @@
/**
* @jest-environment jsdom
*/
import { fireEvent, getByText } from '@testing-library/dom';
import '@testing-library/jest-dom';
import { act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { mockDateField, mockDateTimeField, mockTimeField } from '@staticcms/test/data/fields.mock';
import { createWidgetControlHarness } from '@staticcms/test/harnesses/widget.harness';
import DateTimeControl from '../DateTimeControl';
import type { Matcher, MatcherOptions } from '@testing-library/dom';
import type { UserEvent } from '@testing-library/user-event/dist/types/setup/setup';
import type { DateTimeField } from '../../../interface';
const CLOCK_WIDTH = 220;
const getClockTouchEvent = (value: number, view: 'minutes' | '12hours') => {
let itemCount: number;
if (view === 'minutes') {
itemCount = 60;
} else {
itemCount = 12;
}
const angle = Math.PI / 2 - (Math.PI * 2 * value) / itemCount;
const clientX = Math.round(((1 + Math.cos(angle)) * CLOCK_WIDTH) / 2);
const clientY = Math.round(((1 - Math.sin(angle)) * CLOCK_WIDTH) / 2);
return {
changedTouches: [
{
clientX,
clientY,
},
],
};
};
async function selectCalendarDate(userEventActions: UserEvent, day: number) {
await act(async () => {
const days = document.querySelectorAll('button.MuiPickersDay-root');
await userEventActions.click(days[day - 1]);
});
}
async function selectClockTime(
userEventActions: UserEvent,
hour: number,
minute: number,
ampm: 'am' | 'pm',
) {
const square = document.querySelector('.MuiClock-squareMask');
expect(square).toBeTruthy();
await act(async () => {
if (ampm === 'am') {
const amButton = getByText(document.body, 'AM');
expect(amButton).toBeTruthy();
await userEventActions.click(amButton!);
} else {
const pmButton = getByText(document.body, 'PM');
expect(pmButton).toBeTruthy();
await userEventActions.click(pmButton!);
}
});
await act(async () => {
const hourClockEvent = getClockTouchEvent(hour, '12hours');
fireEvent.touchMove(square!, hourClockEvent);
fireEvent.touchEnd(square!, hourClockEvent);
});
await act(async () => {
const minuteClockEvent = getClockTouchEvent(minute, 'minutes');
fireEvent.touchMove(square!, minuteClockEvent);
fireEvent.touchEnd(square!, minuteClockEvent);
});
}
async function selectDate(
getByTestId: (id: Matcher, options?: MatcherOptions | undefined) => HTMLElement,
day: number,
) {
const userEventActions = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await act(async () => {
const inputWrapper = getByTestId('date-input');
const input = inputWrapper.getElementsByTagName('input')[0];
await userEventActions.click(input);
});
await selectCalendarDate(userEventActions, day);
}
async function selectTime(
getByTestId: (id: Matcher, options?: MatcherOptions | undefined) => HTMLElement,
hour: number,
minute: number,
ampm: 'am' | 'pm',
) {
const userEventActions = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await act(async () => {
const inputWrapper = getByTestId('time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
await userEventActions.click(input);
});
await selectClockTime(userEventActions, hour, minute, ampm);
}
async function selectDateTime(
getByTestId: (id: Matcher, options?: MatcherOptions | undefined) => HTMLElement,
day: number,
hour: number,
minute: number,
ampm: 'am' | 'pm',
) {
const userEventActions = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await act(async () => {
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
await userEventActions.click(input);
});
await selectCalendarDate(userEventActions, day);
await selectClockTime(userEventActions, hour, minute, ampm);
}
describe(DateTimeControl.name, () => {
const renderControl = createWidgetControlHarness(
DateTimeControl,
{ field: mockDateTimeField },
{ useFakeTimers: true },
);
let userEventActions: UserEvent;
beforeEach(() => {
userEventActions = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
});
afterAll(() => {
jest.useRealTimers();
});
it('should render', () => {
const { getByTestId } = renderControl({ label: 'I am a label' });
expect(getByTestId('date-time-input')).toBeInTheDocument();
expect(getByTestId('datetime-now')).toBeInTheDocument();
const label = getByTestId('label');
expect(label.textContent).toBe('I am a label');
expect(label).toHaveClass('text-slate-500');
const field = getByTestId('field');
expect(field).toHaveClass('group/active');
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).not.toHaveClass('mr-14');
// Date Time Widget uses pointer cursor
expect(label).toHaveClass('cursor-pointer');
expect(field).toHaveClass('cursor-pointer');
// Date Time Widget uses default label layout, with bottom padding on field
expect(label).toHaveClass('px-3', 'pt-3');
expect(field).toHaveClass('pb-3');
});
it('should render as single list item', () => {
const { getByTestId } = renderControl({ label: 'I am a label', forSingleList: true });
expect(getByTestId('date-time-input')).toBeInTheDocument();
expect(getByTestId('datetime-now')).toBeInTheDocument();
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).toHaveClass('mr-14');
});
it('should show error', async () => {
const { getByTestId } = renderControl({
errors: [{ type: 'error-type', message: 'i am an error' }],
});
const error = getByTestId('error');
expect(error.textContent).toBe('i am an error');
const field = getByTestId('field');
expect(field).not.toHaveClass('group/active');
const label = getByTestId('label');
expect(label).toHaveClass('text-red-500');
});
describe('datetime', () => {
it("should default to today's date if no default is provided", () => {
const { getByTestId } = renderControl({ label: 'I am a label' });
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-02-12T10:15:35.000');
});
it('should use default if provided', () => {
const { getByTestId } = renderControl({
label: 'I am a label',
field: {
...mockDateTimeField,
default: '2023-01-10T06:23:15.000',
},
});
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-01-10T06:23:15.000');
});
it('should only use prop value as initial value', async () => {
const { rerender, getByTestId } = renderControl({ value: '2023-02-12T10:15:35.000' });
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-02-12T10:15:35.000');
rerender({ value: '2023-02-18T14:37:02.000' });
expect(input).toHaveValue('2023-02-12T10:15:35.000');
});
it('should use prop value exclusively if field is i18n duplicate', async () => {
const { rerender, getByTestId } = renderControl({
field: { ...mockDateTimeField, i18n: 'duplicate' },
duplicate: true,
value: '2023-02-12T10:15:35.000',
});
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-02-12T10:15:35.000');
rerender({ value: '2023-02-18T14:37:02.000' });
expect(input).toHaveValue('2023-02-18T14:37:02.000');
});
it('should disable input and now button if disabled', () => {
const { getByTestId } = renderControl({ label: 'I am a label', disabled: true });
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toBeDisabled();
const nowButton = getByTestId('datetime-now');
expect(nowButton).toBeDisabled();
});
it('should focus current date in modal on field click', async () => {
const { getByTestId } = renderControl();
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).not.toHaveFocus();
await act(async () => {
const field = getByTestId('field');
await userEventActions.click(field);
});
const days = document.querySelectorAll('button.MuiPickersDay-root');
expect(days[11]).toHaveFocus(); // Feb 12th (aka current date)
});
it('should open calendar and allow date selection', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({ label: 'I am a label' });
expect(onChange).not.toHaveBeenCalled();
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
await act(async () => {
await userEventActions.click(input);
});
expect(onChange).not.toHaveBeenCalled();
const days = document.querySelectorAll('button.MuiPickersDay-root');
expect(days.length).toBe(28);
expect(days[0].textContent).toBe('1');
await act(async () => {
await userEventActions.click(days[0]);
});
expect(onChange).toHaveBeenLastCalledWith('2023-02-01T10:15:35.000');
const hours = document.querySelectorAll('.MuiClockNumber-root');
expect(hours.length).toBe(12);
expect(hours[0].textContent).toBe('1');
const square = document.querySelector('.MuiClock-squareMask');
expect(square).toBeTruthy();
await act(async () => {
const hourClockEvent = getClockTouchEvent(1, '12hours');
fireEvent.touchMove(square!, hourClockEvent);
fireEvent.touchEnd(square!, hourClockEvent);
});
expect(onChange).toHaveBeenLastCalledWith('2023-02-01T01:15:35.000');
const minutes = document.querySelectorAll('.MuiClockNumber-root');
expect(minutes.length).toBe(12);
expect(minutes[0].textContent).toBe('05');
await act(async () => {
const minuteClockEvent = getClockTouchEvent(5, 'minutes');
fireEvent.touchMove(square!, minuteClockEvent);
fireEvent.touchEnd(square!, minuteClockEvent);
});
expect(onChange).toHaveBeenLastCalledWith('2023-02-01T01:05:35.000');
});
it('should set value to current date and time when now button is clicked', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
label: 'I am a label',
});
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-02-12T10:15:35.000');
await selectDateTime(getByTestId, 1, 2, 20, 'am');
expect(onChange).toHaveBeenLastCalledWith('2023-02-01T02:20:35.000');
expect(input).toHaveValue('2023-02-01T02:20:35.000');
await act(async () => {
const nowButton = getByTestId('datetime-now');
await userEventActions.click(nowButton);
});
expect(onChange).toHaveBeenLastCalledWith('2023-02-12T10:15:36.000'); // Testing framework moves the time forward by a second by this point
expect(input).toHaveValue('2023-02-12T10:15:36.000');
});
describe('format', () => {
it('uses custom date display format', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
label: 'I am a label',
field: {
...mockDateTimeField,
date_format: 'MM/dd/yyyy',
},
});
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('02/12/2023 10:15:35.000');
await selectDateTime(getByTestId, 1, 2, 20, 'am');
expect(onChange).toHaveBeenLastCalledWith('2023-02-01T02:20:35.000');
expect(input).toHaveValue('02/01/2023 02:20:35.000');
});
it('uses custom time display format', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
label: 'I am a label',
field: {
...mockDateTimeField,
time_format: 'hh:mm aaa',
},
});
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-02-12 10:15 am');
await selectDateTime(getByTestId, 1, 2, 20, 'am');
expect(onChange).toHaveBeenLastCalledWith('2023-02-01T02:20:35.000');
expect(input).toHaveValue('2023-02-01 02:20 am');
});
it('uses custom date and time display formats', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
label: 'I am a label',
field: {
...mockDateTimeField,
date_format: 'MM/dd/yyyy',
time_format: 'hh:mm aaa',
},
});
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('02/12/2023 10:15 am');
await selectDateTime(getByTestId, 1, 2, 20, 'am');
expect(onChange).toHaveBeenLastCalledWith('2023-02-01T02:20:35.000');
expect(input).toHaveValue('02/01/2023 02:20 am');
});
it('uses custom storage format', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
label: 'I am a label',
field: {
...mockDateTimeField,
format: 'yyyy-MM-dd HH:mm',
date_format: 'MM/dd/yyyy',
time_format: 'hh:mm aaa',
},
});
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('02/12/2023 10:15 am');
await selectDateTime(getByTestId, 1, 3, 20, 'pm');
expect(onChange).toHaveBeenLastCalledWith('2023-02-01 15:20');
expect(input).toHaveValue('02/01/2023 03:20 pm');
});
it('uses storage format for display if no display format specified', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
label: 'I am a label',
field: {
...mockDateTimeField,
format: 'yyyy-MM-dd HH:mm',
},
});
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-02-12 10:15');
await selectDateTime(getByTestId, 1, 3, 20, 'pm');
expect(onChange).toHaveBeenLastCalledWith('2023-02-01 15:20');
expect(input).toHaveValue('2023-02-01 15:20');
});
});
describe('utc', () => {
const utcField: DateTimeField = {
...mockDateTimeField,
picker_utc: true,
};
it("should default to today's date if no default is provided", () => {
const { getByTestId } = renderControl({ label: 'I am a label', field: utcField });
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-02-12T15:15:35.000');
});
it('should use default if provided (assuming default is already in UTC)', () => {
const { getByTestId } = renderControl({
label: 'I am a label',
field: {
...utcField,
default: '2023-01-10T06:23:15.000',
},
});
const inputWrapper = getByTestId('date-time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-01-10T06:23:15.000');
});
});
});
describe('date', () => {
it("should default to today's date if no default is provided", () => {
const { getByTestId } = renderControl({ label: 'I am a label', field: mockDateField });
const inputWrapper = getByTestId('date-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-02-12');
});
it('should use default if provided', () => {
const { getByTestId } = renderControl({
label: 'I am a label',
field: {
...mockDateField,
default: '2023-01-10',
},
});
const inputWrapper = getByTestId('date-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-01-10');
});
it('should only use prop value as initial value', async () => {
const { rerender, getByTestId } = renderControl({
field: mockDateField,
value: '2023-02-12',
});
const inputWrapper = getByTestId('date-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-02-12');
rerender({ value: '2023-02-18' });
expect(input).toHaveValue('2023-02-12');
});
it('should use prop value exclusively if field is i18n duplicate', async () => {
const { rerender, getByTestId } = renderControl({
field: { ...mockDateField, i18n: 'duplicate' },
duplicate: true,
value: '2023-02-12',
});
const inputWrapper = getByTestId('date-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-02-12');
rerender({ value: '2023-02-18' });
expect(input).toHaveValue('2023-02-18');
});
it('should disable input and now button if disabled', () => {
const { getByTestId } = renderControl({
label: 'I am a label',
field: mockDateField,
disabled: true,
});
const inputWrapper = getByTestId('date-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toBeDisabled();
const nowButton = getByTestId('datetime-now');
expect(nowButton).toBeDisabled();
});
it('should focus current date in modal on field click', async () => {
const { getByTestId } = renderControl({ field: mockDateField });
const inputWrapper = getByTestId('date-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).not.toHaveFocus();
await act(async () => {
const field = getByTestId('field');
await userEventActions.click(field);
});
const days = document.querySelectorAll('button.MuiPickersDay-root');
expect(days[11]).toHaveFocus(); // Feb 12th (aka current date)
});
it('should open calendar and allow date selection', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({ label: 'I am a label', field: mockDateField });
expect(onChange).not.toHaveBeenCalled();
const inputWrapper = getByTestId('date-input');
const input = inputWrapper.getElementsByTagName('input')[0];
await act(async () => {
await userEventActions.click(input);
});
expect(onChange).not.toHaveBeenCalled();
const days = document.querySelectorAll('button.MuiPickersDay-root');
expect(days.length).toBe(28);
expect(days[0].textContent).toBe('1');
await act(async () => {
await userEventActions.click(days[0]);
});
expect(onChange).toHaveBeenLastCalledWith('2023-02-01');
});
it('should set value to current date when now button is clicked', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
label: 'I am a label',
field: mockDateField,
});
const inputWrapper = getByTestId('date-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-02-12');
await selectDate(getByTestId, 1);
expect(onChange).toHaveBeenLastCalledWith('2023-02-01');
expect(input).toHaveValue('2023-02-01');
await act(async () => {
const nowButton = getByTestId('datetime-now');
await userEventActions.click(nowButton);
});
expect(onChange).toHaveBeenLastCalledWith('2023-02-12');
expect(input).toHaveValue('2023-02-12');
});
describe('format', () => {
it('uses custom date display format', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
label: 'I am a label',
field: {
...mockDateField,
date_format: 'MM/dd/yyyy',
},
});
const inputWrapper = getByTestId('date-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('02/12/2023');
await selectDate(getByTestId, 1);
expect(onChange).toHaveBeenLastCalledWith('2023-02-01');
expect(input).toHaveValue('02/01/2023');
});
it('uses custom storage format', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
label: 'I am a label',
field: {
...mockDateField,
format: 'yyyy-MM-dd',
date_format: 'MM/dd/yyyy',
},
});
const inputWrapper = getByTestId('date-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('02/12/2023');
await selectDate(getByTestId, 1);
expect(onChange).toHaveBeenLastCalledWith('2023-02-01');
expect(input).toHaveValue('02/01/2023');
});
it('uses storage format for display if no display format specified', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
label: 'I am a label',
field: {
...mockDateField,
format: 'yyyy-MM-dd',
},
});
const inputWrapper = getByTestId('date-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-02-12');
await selectDate(getByTestId, 1);
expect(onChange).toHaveBeenLastCalledWith('2023-02-01');
expect(input).toHaveValue('2023-02-01');
});
});
describe('utc', () => {
const utcField: DateTimeField = {
...mockDateField,
picker_utc: true,
};
it("should default to today's date if no default is provided", () => {
const { getByTestId } = renderControl({ label: 'I am a label', field: utcField });
const inputWrapper = getByTestId('date-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-02-12');
});
it('should use default if provided (assuming default is already in UTC)', () => {
const { getByTestId } = renderControl({
label: 'I am a label',
field: {
...utcField,
default: '2023-01-10',
},
});
const inputWrapper = getByTestId('date-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('2023-01-10');
});
});
});
describe('time', () => {
it("should default to today's date if no default is provided", () => {
const { getByTestId } = renderControl({ label: 'I am a label', field: mockTimeField });
const inputWrapper = getByTestId('time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('10:15:35.000');
});
it('should use default if provided', () => {
const { getByTestId } = renderControl({
label: 'I am a label',
field: {
...mockTimeField,
default: '06:23:15.000',
},
});
const inputWrapper = getByTestId('time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('06:23:15.000');
});
it('should only use prop value as initial value', async () => {
const { rerender, getByTestId } = renderControl({
field: mockTimeField,
value: '10:15:35.000',
});
const inputWrapper = getByTestId('time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('10:15:35.000');
rerender({ value: '14:37:02.000' });
expect(input).toHaveValue('10:15:35.000');
});
it('should use prop value exclusively if field is i18n duplicate', async () => {
const { rerender, getByTestId } = renderControl({
field: { ...mockTimeField, i18n: 'duplicate' },
duplicate: true,
value: '10:15:35.000',
});
const inputWrapper = getByTestId('time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('10:15:35.000');
rerender({ value: '14:37:02.000' });
expect(input).toHaveValue('14:37:02.000');
});
it('should disable input and now button if disabled', () => {
const { getByTestId } = renderControl({
label: 'I am a label',
field: mockTimeField,
disabled: true,
});
const inputWrapper = getByTestId('time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toBeDisabled();
const nowButton = getByTestId('datetime-now');
expect(nowButton).toBeDisabled();
});
it('should focus current time in modal on field click', async () => {
const { getByTestId } = renderControl({ field: mockTimeField });
const inputWrapper = getByTestId('time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).not.toHaveFocus();
await act(async () => {
const field = getByTestId('field');
await userEventActions.click(field);
});
const clock = document.querySelector('.MuiClock-wrapper');
expect(clock).toHaveFocus();
});
it('should open calendar and allow date selection', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({ label: 'I am a label', field: mockTimeField });
expect(onChange).not.toHaveBeenCalled();
const inputWrapper = getByTestId('time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
await act(async () => {
await userEventActions.click(input);
});
expect(onChange).not.toHaveBeenCalled();
const hours = document.querySelectorAll('.MuiClockNumber-root');
expect(hours.length).toBe(12);
expect(hours[0].textContent).toBe('1');
const square = document.querySelector('.MuiClock-squareMask');
expect(square).toBeTruthy();
await act(async () => {
const hourClockEvent = getClockTouchEvent(1, '12hours');
fireEvent.touchMove(square!, hourClockEvent);
fireEvent.touchEnd(square!, hourClockEvent);
});
expect(onChange).toHaveBeenLastCalledWith('01:15:35.000');
const minutes = document.querySelectorAll('.MuiClockNumber-root');
expect(minutes.length).toBe(12);
expect(minutes[0].textContent).toBe('05');
await act(async () => {
const minuteClockEvent = getClockTouchEvent(5, 'minutes');
fireEvent.touchMove(square!, minuteClockEvent);
fireEvent.touchEnd(square!, minuteClockEvent);
});
expect(onChange).toHaveBeenLastCalledWith('01:05:35.000');
});
it('should set value to current time when now button is clicked', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
label: 'I am a label',
field: mockTimeField,
});
const inputWrapper = getByTestId('time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('10:15:35.000');
await selectTime(getByTestId, 2, 20, 'am');
expect(onChange).toHaveBeenLastCalledWith('02:20:35.000');
expect(input).toHaveValue('02:20:35.000');
await act(async () => {
const nowButton = getByTestId('datetime-now');
await userEventActions.click(nowButton);
});
expect(onChange).toHaveBeenLastCalledWith('10:15:36.000'); // Testing framework moves the time forward by a second by this point
expect(input).toHaveValue('10:15:36.000');
});
describe('format', () => {
it('uses custom time display format', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
label: 'I am a label',
field: {
...mockTimeField,
time_format: 'hh:mm aaa',
},
});
const inputWrapper = getByTestId('time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('10:15 am');
await selectTime(getByTestId, 2, 20, 'am');
expect(onChange).toHaveBeenLastCalledWith('02:20:35.000');
expect(input).toHaveValue('02:20 am');
});
it('uses custom storage format', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
label: 'I am a label',
field: {
...mockTimeField,
format: 'HH:mm',
time_format: 'hh:mm aaa',
},
});
const inputWrapper = getByTestId('time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('10:15 am');
await selectTime(getByTestId, 3, 20, 'pm');
expect(onChange).toHaveBeenLastCalledWith('15:20');
expect(input).toHaveValue('03:20 pm');
});
it('uses storage format for display if no display format specified', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
label: 'I am a label',
field: {
...mockTimeField,
format: 'HH:mm',
},
});
const inputWrapper = getByTestId('time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('10:15');
await selectTime(getByTestId, 3, 20, 'pm');
expect(onChange).toHaveBeenLastCalledWith('15:20');
expect(input).toHaveValue('15:20');
});
});
describe('utc', () => {
const utcField: DateTimeField = {
...mockTimeField,
picker_utc: true,
};
it("should default to today's date if no default is provided", () => {
const { getByTestId } = renderControl({ label: 'I am a label', field: utcField });
const inputWrapper = getByTestId('time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('15:15:35.000');
});
it('should use default if provided (assuming default is already in UTC)', () => {
const { getByTestId } = renderControl({
label: 'I am a label',
field: {
...utcField,
default: '06:23:15.000',
},
});
const inputWrapper = getByTestId('time-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('06:23:15.000');
});
});
});
});

View File

@ -0,0 +1,44 @@
import { mockDateField, mockDateTimeField, mockTimeField } from '../../../../test/data/fields.mock';
import getDefaultValue from '../getDefaultValue';
describe('DateTime getDefaultValue', () => {
beforeEach(() => {
jest.useFakeTimers({ now: new Date(2023, 1, 12, 10, 15, 35, 0) });
});
afterAll(() => {
jest.useRealTimers();
});
describe('datetime', () => {
it("should use today's date", () => {
expect(getDefaultValue(undefined, mockDateTimeField)).toEqual('2023-02-12T10:15:35.000');
});
it('should use provided default', () => {
expect(getDefaultValue('2022-06-18T14:30:01.000', mockDateTimeField)).toEqual(
'2022-06-18T14:30:01.000',
);
});
});
describe('date', () => {
it("should use today's date", () => {
expect(getDefaultValue(undefined, mockDateField)).toEqual('2023-02-12');
});
it('should use provided default', () => {
expect(getDefaultValue('2022-06-18', mockDateField)).toEqual('2022-06-18');
});
});
describe('time', () => {
it("should use today's date", () => {
expect(getDefaultValue(undefined, mockTimeField)).toEqual('10:15:35.000');
});
it('should use provided default', () => {
expect(getDefaultValue('14:30:01.000', mockTimeField)).toEqual('14:30:01.000');
});
});
});

View File

@ -0,0 +1,15 @@
import { localToUTC, utcToLocal } from '../utc.util';
describe('utc util', () => {
it('converts local (EST) to UTC', () => {
expect(localToUTC(new Date(2023, 1, 12, 10, 5, 35)).toString()).toEqual(
'Sun Feb 12 2023 15:05:35 GMT-0500 (Eastern Standard Time)',
);
});
it('converts UTC to local (EST)', () => {
expect(utcToLocal(new Date(2023, 1, 12, 15, 5, 35)).toString()).toEqual(
'Sun Feb 12 2023 10:05:35 GMT-0500 (Eastern Standard Time)',
);
});
});

View File

@ -0,0 +1,41 @@
import React, { useCallback } from 'react';
import Button from '@staticcms/core/components/common/button/Button';
import type { TranslatedProps } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
export interface NowButtonProps {
handleChange: (value: Date) => void;
disabled: boolean;
}
const NowButton: FC<TranslatedProps<NowButtonProps>> = ({ disabled, t, handleChange }) => {
const handleClick = useCallback(
(event: MouseEvent) => {
event.stopPropagation();
handleChange(new Date());
},
[handleChange],
);
return (
<div
key="now-button-wrapper"
className="absolute inset-y-1 right-3 flex items-center
"
>
<Button
key="now-button"
data-testid="datetime-now"
onClick={handleClick}
disabled={disabled}
variant="outlined"
>
{t('editor.editorWidgets.datetime.now')}
</Button>
</div>
);
};
export default NowButton;

View File

@ -0,0 +1,3 @@
export const DEFAULT_DATE_FORMAT = 'yyyy-MM-dd';
export const DEFAULT_TIME_FORMAT = 'HH:mm:ss.SSS';
export const DEFAULT_DATETIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS";

View File

@ -0,0 +1,32 @@
import formatDate from 'date-fns/format';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { DEFAULT_DATETIME_FORMAT, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from './constants';
import { localToUTC } from './utc.util';
import type { DateTimeField, FieldGetDefaultMethod } from '@staticcms/core/interface';
const getDefaultValue: FieldGetDefaultMethod<string, DateTimeField> = (defaultValue, field) => {
if (isNotNullish(defaultValue)) {
return defaultValue;
}
// dateFormat and timeFormat are strictly for modifying input field with the date/time pickers
const dateFormat: string | boolean = field.date_format ?? true;
// show time-picker? false hides it, true shows it using default format
const timeFormat: string | boolean = field.time_format ?? true;
let finalFormat = field.format;
if (timeFormat === false) {
finalFormat = field.format ?? DEFAULT_DATE_FORMAT;
} else if (dateFormat === false) {
finalFormat = field.format ?? DEFAULT_TIME_FORMAT;
} else {
finalFormat = field.format ?? DEFAULT_DATETIME_FORMAT;
}
const today = field.picker_utc ? localToUTC(new Date()) : new Date();
return formatDate(today, finalFormat);
};
export default getDefaultValue;

View File

@ -0,0 +1,28 @@
import controlComponent from './DateTimeControl';
import previewComponent from './DateTimePreview';
import getDefaultValue from './getDefaultValue';
import schema from './schema';
import type { DateTimeField, WidgetParam } from '@staticcms/core/interface';
const DateTimeWidget = (): WidgetParam<string, DateTimeField> => {
return {
name: 'datetime',
controlComponent,
previewComponent,
options: {
schema,
getDefaultValue,
},
};
};
export * from './utc.util';
export {
controlComponent as DateTimeControl,
previewComponent as DateTimePreview,
schema as dateTimeSchema,
getDefaultValue as dateTimeGetDefaultValue,
};
export default DateTimeWidget;

View File

@ -1,60 +0,0 @@
import formatDate from 'date-fns/format';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import controlComponent, { localToUTC } from './DateTimeControl';
import previewComponent from './DateTimePreview';
import schema from './schema';
import type { DateTimeField, WidgetParam } from '@staticcms/core/interface';
const DateTimeWidget = (): WidgetParam<string, DateTimeField> => {
return {
name: 'datetime',
controlComponent,
previewComponent,
options: {
schema,
getDefaultValue: (defaultValue: string | null | undefined, field: DateTimeField) => {
if (isNotNullish(defaultValue)) {
return defaultValue;
}
const timezoneOffset = new Date().getTimezoneOffset() * 60000;
const format = field.format;
// dateFormat and timeFormat are strictly for modifying input field with the date/time pickers
const dateFormat: string | boolean = field.date_format ?? true;
// show time-picker? false hides it, true shows it using default format
const timeFormat: string | boolean = field.time_format ?? true;
let inputFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX";
if (typeof dateFormat === 'string' || typeof timeFormat === 'string') {
const formatParts: string[] = [];
if (typeof dateFormat === 'string' && isNotEmpty(dateFormat)) {
formatParts.push(dateFormat);
}
if (typeof timeFormat === 'string' && isNotEmpty(timeFormat)) {
formatParts.push(timeFormat);
}
if (formatParts.length > 0) {
inputFormat = formatParts.join(' ');
}
}
const today = field.picker_utc ? localToUTC(new Date(), timezoneOffset) : new Date();
return format ? formatDate(today, format) : formatDate(today, inputFormat);
},
},
};
};
export {
controlComponent as DateTimeControl,
previewComponent as DateTimePreview,
schema as DateTimeSchema,
};
export default DateTimeWidget;

View File

@ -0,0 +1,13 @@
import addMinutes from 'date-fns/addMinutes';
export function localToUTC(dateTime: Date): Date {
return addMinutes(dateTime.getTime(), getTimezoneOffset(dateTime));
}
export function utcToLocal(dateTime: Date): Date {
return addMinutes(dateTime.getTime(), getTimezoneOffset(dateTime) * -1);
}
export function getTimezoneOffset(dateTime: Date): number {
return dateTime.getTimezoneOffset();
}

View File

@ -1,6 +1,5 @@
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import type {
@ -52,11 +51,7 @@ const FileContent: FC<WidgetPreviewProps<string | string[], FileOrImageField>> =
};
const FilePreview: FC<WidgetPreviewProps<string | string[], FileOrImageField>> = props => {
return (
<WidgetPreviewContainer>
{props.value ? <FileContent {...props} /> : null}
</WidgetPreviewContainer>
);
return <div>{props.value ? <FileContent {...props} /> : null}</div>;
};
export default FilePreview;

View File

@ -0,0 +1,276 @@
/**
* @jest-environment jsdom
*/
import '@testing-library/jest-dom';
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { configLoaded } from '@staticcms/core/actions/config';
import {
insertMedia,
mediaDisplayURLSuccess,
mediaInserted,
} from '@staticcms/core/actions/mediaLibrary';
import { store } from '@staticcms/core/store';
import { createMockCollection } from '@staticcms/test/data/collections.mock';
import { createMockConfig } from '@staticcms/test/data/config.mock';
import { mockFileField } from '@staticcms/test/data/fields.mock';
import { createWidgetControlHarness } from '@staticcms/test/harnesses/widget.harness';
import withFileControl from '../withFileControl';
import type { Config, FileOrImageField, MediaFile } from '@staticcms/core/interface';
const FileControl = withFileControl();
jest.mock('@staticcms/core/lib/hooks/useMediaFiles', () => {
const mockMediaFiles: MediaFile[] = [
{
name: 'file1.txt',
key: '12345',
id: '12345',
path: 'path/to/file1.txt',
},
{
name: 'file2.png',
key: '67890',
id: '67890',
path: 'path/to/file2.png',
},
];
return jest.fn(() => mockMediaFiles);
});
jest.mock('@staticcms/core/actions/mediaLibrary', () => ({
...jest.requireActual('@staticcms/core/actions/mediaLibrary'),
closeMediaLibrary: jest.fn(),
deleteMedia: jest.fn(),
insertMedia: jest.fn(),
loadMedia: jest.fn(),
loadMediaDisplayURL: jest.fn(),
persistMedia: jest.fn(),
}));
jest.mock('@staticcms/core/lib/hooks/useMediaAsset', () => (url: string) => url);
describe('File Control', () => {
const collection = createMockCollection({}, mockFileField);
const config = createMockConfig({
collections: [collection],
}) as unknown as Config<FileOrImageField>;
const mockInsertMedia = insertMedia as jest.Mock;
const renderControl = createWidgetControlHarness(
FileControl,
{ field: mockFileField, config },
{ withMediaLibrary: true },
);
beforeEach(() => {
store.dispatch(configLoaded(config as unknown as Config));
store.dispatch(mediaDisplayURLSuccess('12345', 'path/to/file1.txt'));
store.dispatch(mediaDisplayURLSuccess('67890', 'path/to/file2.png'));
mockInsertMedia.mockImplementation((mediaPath: string | string[]) => {
store.dispatch(mediaInserted(mediaPath, ''));
});
// IntersectionObserver isn't available in test environment
const mockIntersectionObserver = jest.fn();
mockIntersectionObserver.mockReturnValue({
observe: () => null,
unobserve: () => null,
disconnect: () => null,
});
window.IntersectionObserver = mockIntersectionObserver;
});
it('should render', () => {
const { getByTestId } = renderControl({ label: 'I am a label' });
const label = getByTestId('label');
expect(label.textContent).toBe('I am a label');
expect(label).toHaveClass('text-slate-500');
const field = getByTestId('field');
expect(field).toHaveClass('group/active');
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).not.toHaveClass('mr-14');
// String Widget uses pointer cursor
expect(label).toHaveClass('cursor-pointer');
expect(field).toHaveClass('cursor-pointer');
// String Widget uses default label layout, without bottom padding on field
expect(label).toHaveClass('px-3', 'pt-3');
expect(field).not.toHaveClass('pb-3');
});
it('should show only the choose upload button by default', () => {
const { getByTestId, queryByTestId } = renderControl({ label: 'I am a label' });
expect(getByTestId('choose-upload')).toBeInTheDocument();
expect(queryByTestId('choose-url')).not.toBeInTheDocument();
expect(queryByTestId('add-replace-upload')).not.toBeInTheDocument();
expect(queryByTestId('replace-url')).not.toBeInTheDocument();
expect(queryByTestId('remove-upload')).not.toBeInTheDocument();
});
it('should show only the choose upload and choose url buttons by default when choose url is true', () => {
const { getByTestId, queryByTestId } = renderControl({
label: 'I am a label',
field: { ...mockFileField, media_library: { choose_url: true } },
});
expect(getByTestId('choose-upload')).toBeInTheDocument();
expect(getByTestId('choose-url')).toBeInTheDocument();
expect(queryByTestId('add-replace-upload')).not.toBeInTheDocument();
expect(queryByTestId('replace-url')).not.toBeInTheDocument();
expect(queryByTestId('remove-upload')).not.toBeInTheDocument();
});
it('should show only the add/replace upload and remove buttons by there is a value', () => {
const { getByTestId, queryByTestId } = renderControl({
label: 'I am a label',
value: 'https://example.com/file.pdf',
});
expect(queryByTestId('choose-upload')).not.toBeInTheDocument();
expect(queryByTestId('choose-url')).not.toBeInTheDocument();
expect(getByTestId('add-replace-upload')).toBeInTheDocument();
expect(queryByTestId('replace-url')).not.toBeInTheDocument();
expect(getByTestId('remove-upload')).toBeInTheDocument();
});
it('should show the add/replace upload, replace url and remove buttons by there is a value and choose url is true', () => {
const { getByTestId, queryByTestId } = renderControl({
label: 'I am a label',
field: { ...mockFileField, media_library: { choose_url: true } },
value: 'https://example.com/file.pdf',
});
expect(queryByTestId('choose-upload')).not.toBeInTheDocument();
expect(queryByTestId('choose-url')).not.toBeInTheDocument();
expect(getByTestId('add-replace-upload')).toBeInTheDocument();
expect(getByTestId('replace-url')).toBeInTheDocument();
expect(getByTestId('remove-upload')).toBeInTheDocument();
});
it('should render as single list item', () => {
const { getByTestId } = renderControl({ label: 'I am a label', forSingleList: true });
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).toHaveClass('mr-14');
});
it('should only use prop value as initial value', async () => {
const { rerender, getByTestId } = renderControl({ value: 'https://example.com/file.pdf' });
const link = getByTestId('link');
expect(link.textContent).toBe('https://example.com/file.pdf');
rerender({ value: 'https://example.com/someoether.pdf' });
expect(link.textContent).toBe('https://example.com/file.pdf');
});
it('should use prop value exclusively if field is i18n duplicate', async () => {
const { rerender, getByTestId } = renderControl({
field: { ...mockFileField, i18n: 'duplicate' },
duplicate: true,
value: 'https://example.com/file.pdf',
});
const link = getByTestId('link');
expect(link.textContent).toBe('https://example.com/file.pdf');
rerender({ value: 'https://example.com/someoether.pdf' });
expect(link.textContent).toBe('https://example.com/someoether.pdf');
});
it('should call onChange when selected file changes', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl();
const uploadButton = getByTestId('choose-upload');
await act(async () => {
await userEvent.click(uploadButton);
});
const file = screen.getByTestId('media-card-path/to/file2.png');
await act(async () => {
await userEvent.click(file);
});
const chooseSelected = screen.getByTestId('choose-selected');
await act(async () => {
await userEvent.click(chooseSelected);
});
await waitFor(() => {
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('path/to/file2.png');
});
});
it('should show error', async () => {
const { getByTestId } = renderControl({
errors: [{ type: 'error-type', message: 'i am an error' }],
});
const error = getByTestId('error');
expect(error.textContent).toBe('i am an error');
const field = getByTestId('field');
expect(field).not.toHaveClass('group/active');
const label = getByTestId('label');
expect(label).toHaveClass('text-red-500');
});
describe('disabled', () => {
it('should show only the choose upload button by default', () => {
const { getByTestId } = renderControl({ label: 'I am a label', disabled: true });
expect(getByTestId('choose-upload')).toBeDisabled();
});
it('should show only the choose upload and choose url buttons by default when choose url is true', () => {
const { getByTestId } = renderControl({
label: 'I am a label',
field: { ...mockFileField, media_library: { choose_url: true } },
disabled: true,
});
expect(getByTestId('choose-upload')).toBeDisabled();
expect(getByTestId('choose-url')).toBeDisabled();
});
it('should show only the add/replace upload and remove buttons by there is a value', () => {
const { getByTestId } = renderControl({
label: 'I am a label',
value: 'https://example.com/file.pdf',
disabled: true,
});
expect(getByTestId('add-replace-upload')).toBeDisabled();
expect(getByTestId('remove-upload')).toBeDisabled();
});
it('should show the add/replace upload, replace url and remove buttons by there is a value and choose url is true', () => {
const { getByTestId } = renderControl({
label: 'I am a label',
field: { ...mockFileField, media_library: { choose_url: true } },
value: 'https://example.com/file.pdf',
disabled: true,
});
expect(getByTestId('add-replace-upload')).toBeDisabled();
expect(getByTestId('replace-url')).toBeDisabled();
expect(getByTestId('remove-upload')).toBeDisabled();
});
});
});

View File

@ -0,0 +1,44 @@
import { CameraAlt as CameraAltIcon } from '@styled-icons/material/CameraAlt';
import { Close as CloseIcon } from '@styled-icons/material/Close';
import React from 'react';
import IconButton from '@staticcms/core/components/common/button/IconButton';
import Image from '@staticcms/core/components/common/image/Image';
import type { Collection, FileOrImageField } from '@staticcms/core/interface';
import type { FC, MouseEventHandler } from 'react';
export interface SortableImageProps {
itemValue: string;
collection: Collection<FileOrImageField>;
field: FileOrImageField;
onRemove: MouseEventHandler;
onReplace: MouseEventHandler;
}
const SortableImage: FC<SortableImageProps> = ({
itemValue,
collection,
field,
onRemove,
onReplace,
}: SortableImageProps) => {
return (
<div>
<div key="image-wrapper">
{/* TODO $sortable */}
<Image key="image" src={itemValue} collection={collection} field={field} />
</div>
<div key="image-buttons-wrapper">
<IconButton key="image-replace" onClick={onReplace}>
<CameraAltIcon key="image-replace-icon" className="h-5 w-5" />
</IconButton>
<IconButton key="image-remove" onClick={onRemove}>
<CloseIcon key="image-remove-icon" className="h-5 w-5" />
</IconButton>
</div>
</div>
);
};
export default SortableImage;

View File

@ -1,195 +1,20 @@
import CloseIcon from '@mui/icons-material/Close';
import PhotoIcon from '@mui/icons-material/Photo';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import { styled } from '@mui/material/styles';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import { borders, effects, lengths, shadows } from '@staticcms/core/components/UI/styles';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import Button from '@staticcms/core/components/common/button/Button';
import Field from '@staticcms/core/components/common/field/Field';
import Image from '@staticcms/core/components/common/image/Image';
import Link from '@staticcms/core/components/common/link/Link';
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { basename, transientOptions } from '@staticcms/core/lib/util';
import { basename } from '@staticcms/core/lib/util';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import SortableImage from './components/SortableImage';
import type {
Collection,
Entry,
FileOrImageField,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { FC, MouseEvent, MouseEventHandler } from 'react';
import type { FileOrImageField, MediaPath, WidgetControlProps } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
const MAX_DISPLAY_LENGTH = 50;
const StyledFileControlWrapper = styled('div')`
display: flex;
flex-direction: column;
position: relative;
width: 100%;
`;
interface StyledFileControlContentProps {
$collapsed: boolean;
}
const StyledFileControlContent = styled(
'div',
transientOptions,
)<StyledFileControlContentProps>(
({ $collapsed }) => `
display: flex;
flex-direction: column;
gap: 16px;
${
$collapsed
? `
display: none;
`
: `
padding: 16px;
`
}
`,
);
const StyledSelection = styled('div')`
display: flex;
flex-direction: column;
`;
const StyledButtonWrapper = styled('div')`
display: flex;
gap: 16px;
`;
interface ImageWrapperProps {
$sortable?: boolean;
}
const ImageWrapper = styled(
'div',
transientOptions,
)<ImageWrapperProps>(
({ $sortable }) => `
flex-basis: 155px;
width: 155px;
height: 100px;
margin-right: 20px;
margin-bottom: 20px;
border: ${borders.textField};
border-radius: ${lengths.borderRadius};
overflow: hidden;
${effects.checkerboard};
${shadows.inset};
cursor: ${$sortable ? 'pointer' : 'auto'};
`,
);
const SortableImageButtonsWrapper = styled('div')`
display: flex;
justify-content: center;
column-gap: 10px;
margin-right: 20px;
margin-top: -10px;
margin-bottom: 10px;
`;
const StyledImage = styled('img')`
width: 100%;
height: 100%;
object-fit: contain;
`;
interface ImageProps {
value: string;
collection: Collection<FileOrImageField>;
field: FileOrImageField;
entry: Entry;
}
const Image: FC<ImageProps> = ({ value, collection, field, entry }) => {
const assetSource = useMediaAsset(value, collection, field, entry);
return <StyledImage key="image" role="presentation" src={assetSource} />;
};
interface SortableImageButtonsProps {
onRemove: MouseEventHandler;
onReplace: MouseEventHandler;
}
const SortableImageButtons: FC<SortableImageButtonsProps> = ({ onRemove, onReplace }) => {
return (
<SortableImageButtonsWrapper key="image-buttons-wrapper">
<IconButton key="image-replace" onClick={onReplace}>
<PhotoIcon key="image-replace-icon" />
</IconButton>
<IconButton key="image-remove" onClick={onRemove}>
<CloseIcon key="image-remove-icon" />
</IconButton>
</SortableImageButtonsWrapper>
);
};
interface SortableImageProps {
itemValue: string;
collection: Collection<FileOrImageField>;
field: FileOrImageField;
entry: Entry;
onRemove: MouseEventHandler;
onReplace: MouseEventHandler;
}
const SortableImage: FC<SortableImageProps> = ({
itemValue,
collection,
field,
entry,
onRemove,
onReplace,
}: SortableImageProps) => {
return (
<div>
<ImageWrapper key="image-wrapper" $sortable>
<Image key="image" value={itemValue} collection={collection} field={field} entry={entry} />
</ImageWrapper>
<SortableImageButtons
key="image-buttons"
onRemove={onRemove}
onReplace={onReplace}
></SortableImageButtons>
</div>
);
};
const StyledMultiImageWrapper = styled('div')`
display: flex;
flex-wrap: wrap;
`;
const FileLink = styled('a')`
margin-bottom: 20px;
font-weight: normal;
color: inherit;
&:hover,
&:active,
&:focus {
text-decoration: underline;
}
`;
const FileLinks = styled('div')`
margin-bottom: 12px;
`;
const FileLinkList = styled('ul')`
list-style-type: none;
`;
function isMultiple(value: string | string[] | null | undefined): value is string[] {
return Array.isArray(value);
}
@ -210,27 +35,31 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
const FileControl: FC<WidgetControlProps<string | string[], FileOrImageField>> = memo(
({
value,
label,
collection,
field,
entry,
isDuplicate,
errors,
forSingleList,
duplicate,
onChange,
openMediaLibrary,
clearMediaControl,
removeMediaControl,
hasErrors,
disabled,
t,
}) => {
const controlID = useUUID();
const [collapsed, setCollapsed] = useState(false);
const [internalRawValue, setInternalValue] = useState(value ?? '');
const internalValue = useMemo(
() => (isDuplicate ? value ?? '' : internalRawValue),
[internalRawValue, isDuplicate, value],
() => (duplicate ? value ?? '' : internalRawValue),
[internalRawValue, duplicate, value],
);
const uploadButtonRef = useRef<HTMLButtonElement | null>(null);
const handleOnChange = useCallback(
(newValue: string | string[]) => {
({ path: newValue }: MediaPath) => {
if (newValue !== internalValue) {
setInternalValue(newValue);
setTimeout(() => {
@ -242,15 +71,11 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
);
const handleOpenMediaLibrary = useMediaInsert(
internalValue,
{ path: internalValue },
{ collection, field, controlID, forImage },
handleOnChange,
);
const handleCollapseToggle = useCallback(() => {
setCollapsed(!collapsed);
}, [collapsed]);
useEffect(() => {
return () => {
removeMediaControl(controlID);
@ -283,7 +108,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
const url = window.prompt(t(`editor.editorWidgets.${subject}.promptUrl`));
handleOnChange(url ?? '');
handleOnChange({ path: url ?? '' });
},
[handleOnChange, t],
);
@ -291,8 +116,9 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
const handleRemove = useCallback(
(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
clearMediaControl(controlID);
handleOnChange('');
handleOnChange({ path: '' });
},
[clearMediaControl, controlID, handleOnChange],
);
@ -302,7 +128,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
if (Array.isArray(internalValue)) {
const newValue = [...internalValue];
newValue.splice(index, 1);
handleOnChange(newValue);
handleOnChange({ path: newValue });
}
},
[handleOnChange, internalValue],
@ -335,18 +161,27 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
// [handleOnChange, internalValue],
// );
const renderFileLink = useCallback((link: string | undefined | null) => {
const size = MAX_DISPLAY_LENGTH;
if (!link || link.length <= size) {
return link;
}
const text = `${link.slice(0, size / 2)}\u2026${link.slice(-(size / 2) + 1)}`;
return (
<FileLink key={`file-link-${text}`} href={link} rel="noopener" target="_blank">
{text}
</FileLink>
);
}, []);
const renderFileLink = useCallback(
(link: string | undefined | null) => {
if (!link) {
return null;
}
const text =
link.length <= MAX_DISPLAY_LENGTH
? link
: `${link.slice(0, MAX_DISPLAY_LENGTH / 2)}\u2026${link.slice(
-(MAX_DISPLAY_LENGTH / 2) + 1,
)}`;
return (
<Link href={link} collection={collection} field={field}>
{text}
</Link>
);
},
[collection, field],
);
const renderedImagesLinks = useMemo(() => {
if (forImage) {
@ -356,87 +191,90 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
if (isMultiple(internalValue)) {
return (
<StyledMultiImageWrapper key="multi-image-wrapper">
<div key="multi-image-wrapper">
{internalValue.map((itemValue, index) => (
<SortableImage
key={`item-${itemValue}`}
itemValue={itemValue}
collection={collection}
field={field}
entry={entry}
onRemove={onRemoveOne(index)}
onReplace={onReplaceOne(index)}
/>
))}
</StyledMultiImageWrapper>
</div>
);
}
return (
<ImageWrapper key="single-image-wrapper">
<Image
key="single-image"
value={internalValue}
collection={collection}
field={field}
entry={entry}
/>
</ImageWrapper>
<div key="single-image-wrapper">
<Image key="single-image" src={internalValue} collection={collection} field={field} />
</div>
);
}
if (isMultiple(internalValue)) {
return (
<FileLinks key="multiple-file-links">
<FileLinkList key="file-links-list">
<div key="mulitple-file-links">
<ul key="file-links-list">
{internalValue.map(val => (
<li key={val}>{renderFileLink(val)}</li>
))}
</FileLinkList>
</FileLinks>
</ul>
</div>
);
}
return <FileLinks key="single-file-links">{renderFileLink(internalValue)}</FileLinks>;
}, [collection, entry, field, internalValue, onRemoveOne, onReplaceOne, renderFileLink]);
return <div key="single-file-links">{renderFileLink(internalValue)}</div>;
}, [collection, field, internalValue, onRemoveOne, onReplaceOne, renderFileLink]);
const content = useMemo(() => {
const content: JSX.Element = useMemo(() => {
const subject = forImage ? 'image' : 'file';
if (Array.isArray(internalValue) ? internalValue.length === 0 : isEmpty(internalValue)) {
return (
<StyledButtonWrapper>
<Button
color="primary"
variant="outlined"
key="upload"
onClick={handleOpenMediaLibrary}
>
{t(`editor.editorWidgets.${subject}.choose${allowsMultiple ? 'Multiple' : ''}`)}
</Button>
{chooseUrl ? (
<div key="selection" className="flex flex-col gap-2 px-3 pt-2 pb-4">
<div key="controls" className="flex gap-2">
<Button
buttonRef={uploadButtonRef}
color="primary"
variant="outlined"
key="choose-url"
onClick={handleUrl(subject)}
key="upload"
onClick={handleOpenMediaLibrary}
data-testid="choose-upload"
disabled={disabled}
>
{t(`editor.editorWidgets.${subject}.chooseUrl`)}
{t(`editor.editorWidgets.${subject}.choose${allowsMultiple ? 'Multiple' : ''}`)}
</Button>
) : null}
</StyledButtonWrapper>
{chooseUrl ? (
<Button
color="primary"
variant="outlined"
key="choose-url"
onClick={handleUrl(subject)}
data-testid="choose-url"
disabled={disabled}
>
{t(`editor.editorWidgets.${subject}.chooseUrl`)}
</Button>
) : null}
</div>
</div>
);
}
return (
<StyledSelection key="selection">
<div key="selection" className="flex flex-col gap-2 px-3 pt-2 pb-4">
{renderedImagesLinks}
<StyledButtonWrapper key="controls">
<div key="controls" className="flex gap-2">
<Button
buttonRef={uploadButtonRef}
color="primary"
variant="outlined"
key="add-replace"
onClick={handleOpenMediaLibrary}
data-testid="add-replace-upload"
disabled={disabled}
>
{t(
`editor.editorWidgets.${subject}.${
@ -450,20 +288,30 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
variant="outlined"
key="replace-url"
onClick={handleUrl(subject)}
data-testid="replace-url"
disabled={disabled}
>
{t(`editor.editorWidgets.${subject}.replaceUrl`)}
</Button>
) : null}
<Button color="error" variant="outlined" key="remove" onClick={handleRemove}>
<Button
color="error"
variant="outlined"
key="remove"
onClick={handleRemove}
data-testid="remove-upload"
disabled={disabled}
>
{t(`editor.editorWidgets.${subject}.remove${allowsMultiple ? 'All' : ''}`)}
</Button>
</StyledButtonWrapper>
</StyledSelection>
</div>
</div>
);
}, [
internalValue,
renderedImagesLinks,
handleOpenMediaLibrary,
disabled,
t,
allowsMultiple,
chooseUrl,
@ -473,20 +321,20 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
return useMemo(
() => (
<StyledFileControlWrapper key="file-control-wrapper">
<ObjectWidgetTopBar
key="file-control-top-bar"
collapsed={collapsed}
onCollapseToggle={handleCollapseToggle}
heading={field.label ?? field.name}
hasError={hasErrors}
t={t}
/>
<StyledFileControlContent $collapsed={collapsed}>{content}</StyledFileControlContent>
<Outline hasError={hasErrors} />
</StyledFileControlWrapper>
<Field
inputRef={uploadButtonRef}
label={label}
errors={errors}
noPadding={!hasErrors}
hint={field.hint}
forSingleList={forSingleList}
cursor="pointer"
disabled={disabled}
>
{content}
</Field>
),
[collapsed, content, field.label, field.name, handleCollapseToggle, hasErrors, t],
[content, disabled, errors, field.hint, forSingleList, hasErrors, label],
);
},
);

View File

@ -1,7 +1,5 @@
import { styled } from '@mui/material/styles';
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import type {
@ -12,18 +10,6 @@ import type {
} from '@staticcms/core/interface';
import type { FC } from 'react';
interface StyledImageProps {
src: string;
}
const StyledImage = styled(({ src }: StyledImageProps) => (
<img src={src || ''} role="presentation" />
))`
display: block;
max-width: 100%;
height: auto;
`;
interface ImageAssetProps {
value: string;
collection: Collection<FileOrImageField>;
@ -34,7 +20,7 @@ interface ImageAssetProps {
const ImageAsset: FC<ImageAssetProps> = ({ value, collection, field, entry }) => {
const assetSource = useMediaAsset(value, collection, field, entry);
return <StyledImage src={assetSource} />;
return <img src={assetSource || ''} role="presentation" />;
};
const ImagePreviewContent: FC<WidgetPreviewProps<string | string[], FileOrImageField>> = ({
@ -61,11 +47,7 @@ const ImagePreviewContent: FC<WidgetPreviewProps<string | string[], FileOrImageF
};
const ImagePreview: FC<WidgetPreviewProps<string | string[], FileOrImageField>> = props => {
return (
<WidgetPreviewContainer>
{props.value ? <ImagePreviewContent {...props} /> : null}
</WidgetPreviewContainer>
);
return <div>{props.value ? <ImagePreviewContent {...props} /> : null}</div>;
};
export default ImagePreview;

View File

@ -16,8 +16,6 @@ export * from './map';
export { default as MapWidget } from './map';
export * from './markdown';
export { default as MarkdownWidget } from './markdown';
export * from './mdx';
export { default as MdxWidget } from './mdx';
export * from './number';
export { default as NumberWidget } from './number';
export * from './object';

View File

@ -1,17 +1,18 @@
import { DndContext } from '@dnd-kit/core';
import { SortableContext, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { styled } from '@mui/material/styles';
import { arrayMoveImmutable } from 'array-move';
import isEmpty from 'lodash/isEmpty';
import React, { useCallback, useMemo, useState } from 'react';
import { v4 as uuid } from 'uuid';
import FieldLabel from '@staticcms/core/components/UI/FieldLabel';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import transientOptions from '@staticcms/core/lib/util/transientOptions';
import ListItem from './ListItem';
import Button from '@staticcms/core/components/common/button/Button';
import Menu from '@staticcms/core/components/common/menu/Menu';
import MenuGroup from '@staticcms/core/components/common/menu/MenuGroup';
import MenuItemButton from '@staticcms/core/components/common/menu/MenuItemButton';
import useHasChildErrors from '@staticcms/core/lib/hooks/useHasChildErrors';
import classNames from '@staticcms/core/lib/util/classNames.util';
import ListFieldWrapper from './components/ListFieldWrapper';
import ListItem from './components/ListItem';
import { resolveFieldKeyType, TYPES_KEY } from './typedListHelpers';
import type { DragEndEvent } from '@dnd-kit/core';
@ -22,42 +23,19 @@ import type {
I18nSettings,
ListField,
ObjectValue,
UnknownField,
ValueOrNestedValue,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
const StyledListWrapper = styled('div')`
position: relative;
width: 100%;
`;
function arrayMoveImmutable<T>(array: T[], oldIndex: number, newIndex: number): T[] {
const newArray = [...array];
interface StyledSortableListProps {
$collapsed: boolean;
newArray.splice(newIndex, 0, newArray.splice(oldIndex, 1)[0]);
return newArray;
}
const StyledSortableList = styled(
'div',
transientOptions,
)<StyledSortableListProps>(
({ $collapsed }) => `
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
${
$collapsed
? `
display: none;
`
: `
padding: 16px;
`
}
`,
);
interface SortableItemProps {
id: string;
item: ValueOrNestedValue;
@ -68,10 +46,9 @@ interface SortableItemProps {
field: ListField;
fieldsErrors: FieldsErrors;
submitted: boolean;
isDuplicate: boolean;
isFieldDuplicate: ((field: Field<UnknownField>) => boolean) | undefined;
isHidden: boolean;
isFieldHidden: ((field: Field<UnknownField>) => boolean) | undefined;
disabled: boolean;
duplicate: boolean;
hidden: boolean;
locale: string | undefined;
path: string;
value: Record<string, ObjectValue>;
@ -88,10 +65,9 @@ const SortableItem: FC<SortableItemProps> = ({
field,
fieldsErrors,
submitted,
isDuplicate,
isFieldDuplicate,
isHidden,
isFieldHidden,
disabled,
duplicate,
hidden,
locale,
path,
i18n,
@ -101,7 +77,7 @@ const SortableItem: FC<SortableItemProps> = ({
});
const style = {
transform: CSS.Transform.toString(transform),
transform: CSS.Translate.toString(transform),
transition,
};
@ -110,7 +86,18 @@ const SortableItem: FC<SortableItemProps> = ({
}
return (
<div ref={setNodeRef} data-testid={`object-control-${index}`} style={style} {...attributes}>
<div
ref={setNodeRef}
data-testid={`object-control-${index}`}
style={style}
{...(disabled ? {} : attributes)}
className={classNames(
`
first:pt-0
`,
field.fields?.length !== 1 && 'pt-1',
)}
>
<ListItem
index={index}
id={id}
@ -122,10 +109,9 @@ const SortableItem: FC<SortableItemProps> = ({
field={field}
fieldsErrors={fieldsErrors}
submitted={submitted}
isDuplicate={isDuplicate}
isFieldDuplicate={isFieldDuplicate}
isHidden={isHidden}
isFieldHidden={isFieldHidden}
disabled={disabled}
duplicate={duplicate}
hidden={hidden}
locale={locale}
path={path}
value={item}
@ -194,20 +180,19 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({
field,
fieldsErrors,
submitted,
isDuplicate,
isFieldDuplicate,
isHidden,
isFieldHidden,
disabled,
duplicate,
hidden,
locale,
onChange,
path,
t,
value,
i18n,
hasErrors,
errors,
forSingleList,
onChange,
t,
}) => {
const internalValue = useMemo(() => value ?? [], [value]);
const [collapsed, setCollapsed] = useState(field.collapsed ?? true);
const [keys, setKeys] = useState(Array.from({ length: internalValue.length }, () => uuid()));
const valueType = useMemo(() => {
@ -251,7 +236,6 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({
}
setKeys(newKeys);
onChange(newValue as string[] | ObjectValue[]);
setCollapsed(false);
},
[field.add_to_top, onChange, internalValue, keys],
);
@ -289,14 +273,6 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({
[onChange, internalValue, keys],
);
const handleCollapseAllToggle = useCallback(
(e: MouseEvent) => {
e.preventDefault();
setCollapsed(!collapsed);
},
[collapsed],
);
const handleDragEnd = useCallback(
({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
@ -317,6 +293,8 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({
[onChange, internalValue, keys],
);
const hasChildErrors = useHasChildErrors(path, fieldsErrors, i18n);
if (valueType === null) {
return null;
}
@ -325,62 +303,96 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({
const labelSingular = field.label_singular ? field.label_singular : field.label ?? field.name;
const listLabel = internalValue.length === 1 ? labelSingular : label;
return (
<StyledListWrapper key="list-widget">
<FieldLabel key="label">{label}</FieldLabel>
<ObjectWidgetTopBar
key="header"
allowAdd={field.allow_add ?? true}
onAdd={handleAdd}
types={field[TYPES_KEY] ?? []}
onAddType={type => handleAddType(type, resolveFieldKeyType(field))}
heading={`${internalValue.length} ${listLabel}`}
label={labelSingular}
onCollapseToggle={handleCollapseAllToggle}
collapsed={collapsed}
hasError={hasErrors}
t={t}
testId="list-header"
/>
{internalValue.length > 0 ? (
<DndContext key="dnd-context" onDragEnd={handleDragEnd}>
<SortableContext items={keys}>
<StyledSortableList $collapsed={collapsed}>
{internalValue.map((item, index) => {
const key = keys[index];
if (!key) {
return null;
}
const types = field[TYPES_KEY];
return (
<SortableItem
index={index}
key={key}
id={key}
item={item}
valueType={valueType}
handleRemove={handleRemove}
entry={entry}
field={field}
fieldsErrors={fieldsErrors}
submitted={submitted}
isDuplicate={isDuplicate}
isFieldDuplicate={isFieldDuplicate}
isHidden={isHidden}
isFieldHidden={isFieldHidden}
locale={locale}
path={path}
value={item as Record<string, ObjectValue>}
i18n={i18n}
/>
);
})}
</StyledSortableList>
</SortableContext>
</DndContext>
) : null}
<Outline key="outline" hasLabel hasError={hasErrors} />
</StyledListWrapper>
return (
<div key="list-widget">
<ListFieldWrapper
key="list-control-wrapper"
field={field}
openLabel={label}
closedLabel={listLabel}
errors={errors}
hasChildErrors={hasChildErrors}
hint={field.hint}
forSingleList={forSingleList}
disabled={disabled}
>
{internalValue.length > 0 ? (
<DndContext key="dnd-context" id="dnd-context" onDragEnd={handleDragEnd}>
<SortableContext items={keys}>
<div data-testid="list-widget-children">
{internalValue.map((item, index) => {
const key = keys[index];
if (!key) {
return null;
}
return (
<SortableItem
index={index}
key={key}
id={key}
item={item}
valueType={valueType}
handleRemove={handleRemove}
entry={entry}
field={field}
fieldsErrors={fieldsErrors}
submitted={submitted}
disabled={disabled}
duplicate={duplicate}
hidden={hidden}
locale={locale}
path={path}
value={item as Record<string, ObjectValue>}
i18n={i18n}
/>
);
})}
</div>
</SortableContext>
</DndContext>
) : null}
{field.allow_add !== false ? (
<div className="py-3 px-4 w-full">
{types && types.length ? (
<Menu
label={t('editor.editorWidgets.list.addType', { item: label })}
variant="outlined"
className="w-full z-20"
data-testid="list-type-add"
disabled={disabled}
>
<MenuGroup>
{types.map((type, idx) =>
type ? (
<MenuItemButton
key={idx}
onClick={() => handleAddType(type.name, resolveFieldKeyType(field))}
data-testid={`list-type-add-item-${type.name}`}
>
{type.label ?? type.name}
</MenuItemButton>
) : null,
)}
</MenuGroup>
</Menu>
) : (
<Button
variant="outlined"
onClick={handleAdd}
className="w-full"
data-testid="list-add"
disabled={disabled}
>
{t('editor.editorWidgets.list.add', { item: labelSingular })}
</Button>
)}
</div>
) : null}
</ListFieldWrapper>
</div>
);
};

View File

@ -1,14 +1,12 @@
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import type { ListField, ValueOrNestedValue, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const ListPreview: FC<WidgetPreviewProps<ValueOrNestedValue[], ListField>> = ({ field, value }) => {
if (field.fields && field.fields.length === 1) {
return (
<WidgetPreviewContainer>
<div>
<label>
<strong>{field.name}:</strong>
</label>
@ -17,11 +15,11 @@ const ListPreview: FC<WidgetPreviewProps<ValueOrNestedValue[], ListField>> = ({
<li key={String(item)}>{String(item)}</li>
))}
</ul>
</WidgetPreviewContainer>
</div>
);
}
return <WidgetPreviewContainer>{field.renderedFields ?? null}</WidgetPreviewContainer>;
return <div>{field.renderedFields ?? null}</div>;
};
export default ListPreview;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,151 @@
import Collapse from '@mui/material/Collapse';
import { ChevronRight as ChevronRightIcon } from '@styled-icons/material/ChevronRight';
import React, { useCallback, useMemo, useState } from 'react';
import ErrorMessage from '@staticcms/core/components/common/field/ErrorMessage';
import Hint from '@staticcms/core/components/common/field/Hint';
import Label from '@staticcms/core/components/common/field/Label';
import classNames from '@staticcms/core/lib/util/classNames.util';
import type { FieldError, ListField } from '@staticcms/core/interface';
import type { FC, ReactNode } from 'react';
export interface ListFieldWrapperProps {
field: ListField;
openLabel: string;
closedLabel: string;
children: ReactNode | ReactNode[];
errors: FieldError[];
hasChildErrors: boolean;
hint?: string;
forSingleList: boolean;
disabled: boolean;
}
const ListFieldWrapper: FC<ListFieldWrapperProps> = ({
field,
openLabel,
closedLabel,
children,
errors,
hasChildErrors,
hint,
forSingleList,
disabled,
}) => {
const hasErrors = useMemo(() => errors.length > 0, [errors.length]);
const [open, setOpen] = useState(!field.collapsed ?? true);
const handleOpenToggle = useCallback(() => {
setOpen(oldOpen => !oldOpen);
}, []);
return (
<div
data-testid="list-field"
className={classNames(
`
relative
flex
flex-col
border-b
border-slate-400
focus-within:border-blue-800
dark:focus-within:border-blue-100
`,
!(hasErrors || hasChildErrors) && 'group/active-list',
)}
>
<div
data-testid="field-wrapper"
className={classNames(
`
relative
flex
flex-col
w-full
`,
forSingleList && 'mr-14',
)}
>
<button
data-testid="list-expand-button"
className="
flex
w-full
justify-between
px-3
py-2
text-left
text-sm
font-medium
focus:outline-none
focus-visible:ring
gap-2
focus-visible:ring-opacity-75
items-center
"
onClick={handleOpenToggle}
>
<Label
key="label"
hasErrors={hasErrors || hasChildErrors}
className={classNames(
!disabled &&
`
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500
`,
)}
cursor="pointer"
variant="inline"
disabled={disabled}
>
{open ? openLabel : closedLabel}
</Label>
<ChevronRightIcon
className={classNames(
open && 'rotate-90 transform',
`
transition-transform
h-5
w-5
`,
disabled
? `
text-slate-300
dark:text-slate-600
`
: `
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500
`,
)}
/>
</button>
<Collapse in={open} appear={false}>
<div
className={classNames(
`
text-sm
text-gray-500
`,
(hasErrors || hasChildErrors) && 'border-l-red-500',
)}
>
<div data-testid="object-fields">{children}</div>
</div>
</Collapse>
{hint ? (
<Hint key="hint" hasErrors={hasErrors} cursor="pointer" disabled={disabled}>
{hint}
</Hint>
) : null}
<ErrorMessage errors={errors} />
</div>
</div>
);
};
export default ListFieldWrapper;

View File

@ -1,18 +1,15 @@
import { styled } from '@mui/material/styles';
import partial from 'lodash/partial';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useMemo } from 'react';
import EditorControl from '@staticcms/core/components/editor/EditorControlPane/EditorControl';
import ListItemTopBar from '@staticcms/core/components/UI/ListItemTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import { colors } from '@staticcms/core/components/UI/styles';
import transientOptions from '@staticcms/core/lib/util/transientOptions';
import EditorControl from '@staticcms/core/components/entry-editor/editor-control-pane/EditorControl';
import useHasChildErrors from '@staticcms/core/lib/hooks/useHasChildErrors';
import {
addFileTemplateFields,
compileStringTemplate,
} from '@staticcms/core/lib/widgets/stringTemplate';
import { ListValueType } from './ListControl';
import { getTypedFieldForValue } from './typedListHelpers';
import ListItemWrapper from '@staticcms/list/components/ListItemWrapper';
import { ListValueType } from '../ListControl';
import { getTypedFieldForValue } from '../typedListHelpers';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import type {
@ -26,37 +23,12 @@ import type {
} from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
const StyledListItem = styled('div')`
position: relative;
`;
const StyledListItemTopBar = styled(ListItemTopBar)`
background-color: ${colors.textFieldBorder};
`;
interface StyledObjectFieldWrapperProps {
$collapsed: boolean;
}
const StyledObjectFieldWrapper = styled(
'div',
transientOptions,
)<StyledObjectFieldWrapperProps>(
({ $collapsed }) => `
display: flex;
flex-direction: column;
gap: 16px;
${
$collapsed
? `
display: none;
`
: ''
}
`,
);
function handleSummary(summary: string, entry: Entry, label: string, item: ValueOrNestedValue) {
function handleSummary(
summary: string,
entry: Entry,
label: string,
item: ValueOrNestedValue,
): string {
if (typeof item === 'object' && !Array.isArray(item)) {
const labeledItem: EntryData = {
...item,
@ -68,7 +40,7 @@ function handleSummary(summary: string, entry: Entry, label: string, item: Value
return compileStringTemplate(summary, null, '', data);
}
return item;
return String(item);
}
function validateItem(field: ListField, item: ValueOrNestedValue) {
@ -93,10 +65,9 @@ interface ListItemProps
| 'field'
| 'fieldsErrors'
| 'submitted'
| 'isDuplicate'
| 'isFieldDuplicate'
| 'isHidden'
| 'isFieldHidden'
| 'disabled'
| 'duplicate'
| 'hidden'
| 'locale'
| 'path'
| 'value'
@ -116,19 +87,18 @@ const ListItem: FC<ListItemProps> = ({
field,
fieldsErrors,
submitted,
isDuplicate,
isFieldDuplicate,
isHidden,
isFieldHidden,
disabled,
duplicate,
hidden,
locale,
path,
valueType,
handleRemove,
value,
i18n,
listeners,
handleRemove,
}) => {
const [objectLabel, objectField] = useMemo((): [string, ListField | ObjectField] => {
const [summary, objectField] = useMemo((): [string, ListField | ObjectField] => {
const childObjectField: ObjectField = {
name: `${index}`,
label: field.label,
@ -152,17 +122,19 @@ const ListItem: FC<ListItemProps> = ({
const mixedObjectValue = objectValue as ObjectValue;
const itemType = getTypedFieldForValue(field, mixedObjectValue, index);
const [itemTypeName, itemType] = getTypedFieldForValue(field, mixedObjectValue, index);
if (!itemType) {
return [base, childObjectField];
}
const label = itemType.label ?? itemType.name;
const label = itemType.label ?? itemTypeName;
// each type can have its own summary, but default to the list summary if exists
const summary = ('summary' in itemType && itemType.summary) ?? field.summary;
const summary =
'summary' in itemType && itemType.summary ? itemType.summary : field.summary;
const labelReturn = summary
? `${label} - ${handleSummary(summary, entry, label, mixedObjectValue)}`
: label;
return [labelReturn, itemType];
}
case ListValueType.MULTIPLE: {
@ -186,20 +158,14 @@ const ListItem: FC<ListItemProps> = ({
const summary = field.summary;
const labelReturn = summary
? handleSummary(summary, entry, String(labelFieldValue), objectValue)
: labelFieldValue;
return [(labelReturn || `No ${labelField.name}`).toString(), childObjectField];
: String(labelFieldValue);
return [labelReturn, childObjectField];
}
}
}, [entry, field, index, value, valueType]);
const [collapsed, setCollapsed] = useState(false);
const handleCollapseToggle = useCallback(
(event: MouseEvent) => {
event.stopPropagation();
setCollapsed(!collapsed);
},
[collapsed],
);
const hasChildErrors = useHasChildErrors(path, fieldsErrors, i18n);
const finalValue = useMemo(() => {
if (field.fields && field.fields.length === 1) {
@ -211,40 +177,39 @@ const ListItem: FC<ListItemProps> = ({
return value;
}, [field.fields, value]);
const isSingleList = useMemo(() => field.fields?.length === 1, [field.fields?.length]);
return (
<StyledListItem key="sortable-list-item">
<>
<StyledListItemTopBar
key="list-item-top-bar"
collapsed={collapsed}
onCollapseToggle={handleCollapseToggle}
onRemove={partial(handleRemove, index)}
data-testid={`list-item-top-bar-${id}`}
title={objectLabel}
isVariableTypesList={valueType === ListValueType.MIXED}
listeners={listeners}
<div key="sortable-list-item">
<ListItemWrapper
key="list-item-top-bar"
collapsed={field.collapsed}
onRemove={partial(handleRemove, index)}
data-testid={`list-item-top-bar-${id}`}
label={field.label_singular ?? field.label ?? field.name}
summary={summary}
listeners={listeners}
hasErrors={hasChildErrors}
isSingleField={isSingleList}
disabled={disabled}
>
<EditorControl
key={`control-${id}`}
field={objectField}
value={finalValue}
fieldsErrors={fieldsErrors}
submitted={submitted}
parentPath={path}
disabled={disabled || duplicate}
parentDuplicate={duplicate}
parentHidden={hidden}
locale={locale}
i18n={i18n}
forList={true}
forSingleList={isSingleList}
/>
<StyledObjectFieldWrapper $collapsed={collapsed}>
<EditorControl
key={`control-${id}`}
field={objectField}
value={finalValue}
fieldsErrors={fieldsErrors}
submitted={submitted}
parentPath={path}
isDisabled={isDuplicate}
isParentDuplicate={isDuplicate}
isFieldDuplicate={isFieldDuplicate}
isParentHidden={isHidden}
isFieldHidden={isFieldHidden}
locale={locale}
i18n={i18n}
forList={true}
/>
</StyledObjectFieldWrapper>
<Outline key="outline" />
</>
</StyledListItem>
</ListItemWrapper>
</div>
);
};

View File

@ -0,0 +1,241 @@
import Collapse from '@mui/material/Collapse';
import { ChevronRight as ChevronRightIcon } from '@styled-icons/material/ChevronRight';
import { Close as CloseIcon } from '@styled-icons/material/Close';
import { Menu as MenuIcon } from '@styled-icons/material/Menu';
import React, { useCallback, useMemo, useState } from 'react';
import IconButton from '@staticcms/core/components/common/button/IconButton';
import Label from '@staticcms/core/components/common/field/Label';
import classNames from '@staticcms/core/lib/util/classNames.util';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import type { MouseEvent, ReactNode } from 'react';
export interface DragHandleProps {
listeners: SyntheticListenerMap | undefined;
disabled: boolean;
}
const DragHandle = ({ listeners, disabled }: DragHandleProps) => {
return (
<span data-testid="drag-handle" className="flex items-center" {...(disabled ? {} : listeners)}>
<MenuIcon
className="
h-3
w-3
"
/>
</span>
);
};
export interface ListItemWrapperProps {
className?: string;
label: string;
summary: string;
collapsed?: boolean;
onRemove: (event: MouseEvent) => void;
listeners: SyntheticListenerMap | undefined;
hasErrors: boolean;
children: ReactNode;
isSingleField: boolean;
disabled: boolean;
}
const ListItemWrapper = ({
label,
summary,
collapsed = false,
onRemove,
listeners,
hasErrors,
children,
isSingleField,
disabled,
}: ListItemWrapperProps) => {
const [open, setOpen] = useState(!collapsed);
const handleOpenToggle = useCallback(() => {
setOpen(oldOpen => !oldOpen);
}, []);
const renderedControls = useMemo(
() => (
<div className="flex gap-2 items-center">
{onRemove ? (
<IconButton
data-testid="remove-button"
size="small"
variant="text"
onClick={onRemove}
disabled={disabled}
>
<CloseIcon
className="
h-5
w-5
"
/>
</IconButton>
) : null}
{listeners ? <DragHandle listeners={listeners} disabled={disabled} /> : null}
</div>
),
[disabled, listeners, onRemove],
);
if (isSingleField) {
return (
<div
data-testid="list-item-field"
className={classNames(
`
relative
flex
flex-col
`,
!hasErrors && 'group/active-list-item',
)}
>
<div
data-testid="list-item-objects"
className={classNames(
`
relative
ml-4
text-sm
text-gray-500
border-l-2
border-solid
border-l-slate-400
`,
!disabled && 'group-focus-within/active-list-item:border-l-blue-500',
hasErrors && 'border-l-red-500',
)}
>
{children}
<div className="absolute right-3 top-0 h-full flex items-center justify-end z-10">
{renderedControls}
</div>
</div>
</div>
);
}
return (
<div
data-testid="list-item-field"
className={classNames(
`
relative
flex
flex-col
`,
!hasErrors && 'group/active-list-item',
)}
>
<div
className="
flex
w-full
pr-3
text-left
text-sm
gap-2
items-center
"
>
<button
data-testid="list-item-expand-button"
className="
flex
w-full
pl-2
py-2
text-left
text-sm
font-medium
focus:outline-none
focus-visible:ring
gap-2
focus-visible:ring-opacity-75
items-center
"
onClick={handleOpenToggle}
>
<ChevronRightIcon
className={classNames(
open && 'rotate-90 transform',
`
transition-transform
h-5
w-5
`,
disabled
? `
text-slate-300
dark:text-slate-600
`
: `
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500
`,
)}
/>
<div className="flex-grow">
<Label
key="label"
hasErrors={hasErrors}
className={classNames(
!disabled &&
`
group-focus-within/active-list-item:text-blue-500
group-hover/active-list-item:text-blue-500
`,
)}
cursor="pointer"
variant="inline"
data-testid="item-label"
disabled={disabled}
>
{label}
</Label>
{!open ? <span data-testid="item-summary">{summary}</span> : null}
</div>
</button>
{renderedControls}
</div>
{!open ? (
<div
className="
ml-8
border-b
border-slate-400
focus-within:border-blue-800
dark:focus-within:border-blue-100
"
></div>
) : null}
<Collapse in={open} appear={false}>
<div
className={classNames(
`
ml-4
text-sm
text-gray-500
border-l-2
border-solid
border-l-slate-400
`,
!disabled && 'group-focus-within/active-list-item:border-l-blue-500',
hasErrors && 'border-l-red-500',
)}
>
{children}
</div>
</Collapse>
</div>
);
};
export default ListItemWrapper;

View File

@ -15,7 +15,7 @@ const ListWidget = (): WidgetParam<ValueOrNestedValue[], ListField> => {
};
};
export { default as ListItem } from './ListItem';
export { default as ListItem } from './components/ListItem';
export * from './typedListHelpers';
export { controlComponent as ListControl, previewComponent as ListPreview, schema as ListSchema };

View File

@ -8,19 +8,22 @@ export function getTypedFieldForValue(
field: ListField,
value: ObjectValue | undefined | null,
index: number,
): ObjectField | undefined {
): [string, ObjectField | undefined] {
const typeKey = resolveFieldKeyType(field);
const types = field[TYPES_KEY] ?? [];
const types = field.types ?? [];
const valueType = value?.[typeKey] ?? {};
const typeField = types.find(type => type.name === valueType);
if (!typeField) {
return typeField;
return ['', typeField];
}
return {
...typeField,
name: `${index}`,
};
return [
typeField.name,
{
...typeField,
name: `${index}`,
},
];
}
export function resolveFunctionForTypedField(field: ListField) {

View File

@ -1,12 +1,10 @@
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import type { MapField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const MapPreview: FC<WidgetPreviewProps<string, MapField>> = ({ value }) => {
return <WidgetPreviewContainer>{value}</WidgetPreviewContainer>;
return <div>{value}</div>;
};
export default MapPreview;

View File

@ -1,61 +1,19 @@
import { css, styled } from '@mui/material/styles';
import GeoJSON from 'ol/format/GeoJSON';
import Draw from 'ol/interaction/Draw';
import TileLayer from 'ol/layer/Tile';
import VectorLayer from 'ol/layer/Vector';
import Map from 'ol/Map.js';
import olStyles from 'ol/ol.css';
import OSMSource from 'ol/source/OSM';
import VectorSource from 'ol/source/Vector';
import View from 'ol/View.js';
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import React, { useLayoutEffect, useRef } from 'react';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import transientOptions from '@staticcms/core/lib/util/transientOptions';
import Field from '@staticcms/core/components/common/field/Field';
import type { MapField, WidgetControlProps } from '@staticcms/core/interface';
import type { Geometry } from 'ol/geom';
import type { FC } from 'react';
const StyledMapControlWrapper = styled('div')`
display: flex;
flex-direction: column;
position: relative;
width: 100%;
`;
interface StyledMapControlContentProps {
$collapsed: boolean;
$height: string;
}
const StyledMapControlContent = styled(
'div',
transientOptions,
)<StyledMapControlContentProps>(
({ $collapsed, $height }) => `
display: flex;
position: relative;
height: ${$height}
${
$collapsed
? `
display: none;
`
: ''
}
`,
);
const StyledMap = styled('div')`
width: 100%;
position: relative;
${css`
${olStyles}
`}
`;
const formatOptions = {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
@ -84,15 +42,11 @@ const withMapControl = ({ getFormat, getMap }: WithMapControlProps = {}) => {
value,
field,
onChange,
hasErrors,
errors,
forSingleList,
label,
t,
disabled,
}) => {
const [collapsed, setCollapsed] = useState(false);
const handleCollapseToggle = useCallback(() => {
setCollapsed(!collapsed);
}, [collapsed]);
const { height = '400px' } = field;
const mapContainer = useRef<HTMLDivElement | null>(null);
@ -120,28 +74,37 @@ const withMapControl = ({ getFormat, getMap }: WithMapControlProps = {}) => {
const writeOptions = { decimals: field.decimals ?? 7 };
draw.on('drawend', ({ feature }) => {
featuresSource.clear();
if (disabled) {
return;
}
const geometry = feature.getGeometry();
if (geometry) {
onChange(format.writeGeometry(geometry, writeOptions));
}
});
}, [field, mapContainer, onChange, path, value]);
}, [disabled, field, mapContainer, onChange, path, value]);
return (
<StyledMapControlWrapper>
<ObjectWidgetTopBar
key="file-control-top-bar"
collapsed={collapsed}
onCollapseToggle={handleCollapseToggle}
heading={label}
hasError={hasErrors}
t={t}
<Field
label={label}
errors={errors}
hint={field.hint}
forSingleList={forSingleList}
noPadding
disabled={disabled}
>
<div
ref={mapContainer}
className="
relative
w-full
mt-2
"
style={{ height }}
/>
<StyledMapControlContent $collapsed={collapsed} $height={height}>
<StyledMap ref={mapContainer} />
</StyledMapControlContent>
<Outline hasError={hasErrors} />
</StyledMapControlWrapper>
</Field>
);
};

View File

@ -2,12 +2,12 @@ import { MDXProvider } from '@mdx-js/react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { VFileMessage } from 'vfile-message';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import { withMdxImage } from '@staticcms/core/components/common/image/Image';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { getShortcodes } from '../../lib/registry';
import withShortcodeMdxComponent from './mdx/withShortcodeMdxComponent';
import useMdx from './plate/hooks/useMdx';
import { processShortcodeConfigToMdx } from './plate/serialization/slate/processShortcodeConfig';
import { withMdxImage } from '@staticcms/core/components/common/image/Image';
import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
@ -29,6 +29,8 @@ function FallbackComponent({ error }: FallbackComponentProps) {
const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewProps => {
const { value, collection, field } = previewProps;
const id = useUUID();
const components = useMemo(
() => ({
Shortcode: withShortcodeMdxComponent({ previewProps }),
@ -37,7 +39,7 @@ const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewPr
[collection, field, previewProps],
);
const [state, setValue] = useMdx(value ?? '');
const [state, setValue] = useMdx(`editor-${id}.mdx`, value ?? '');
const [prevValue, setPrevValue] = useState('');
useEffect(() => {
if (prevValue !== value) {
@ -66,13 +68,13 @@ const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewPr
}
return (
<WidgetPreviewContainer>
<div>
{state.file && state.file.result ? (
<MDXProvider components={components}>
<MdxComponent />
</MDXProvider>
) : null}
</WidgetPreviewContainer>
</div>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [MdxComponent]);

View File

@ -1,4 +1,3 @@
import { styled } from '@mui/material/styles';
import {
createAlignPlugin,
createAutoformatPlugin,
@ -56,10 +55,9 @@ import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { withShortcodeElement } from './components';
import { CodeBlockElement, withShortcodeElement } from './components';
import { BalloonToolbar } from './components/balloon-toolbar';
import { BlockquoteElement } from './components/nodes/blockquote';
import { CodeBlockElement } from './components/nodes/code-block';
import {
Heading1,
Heading2,
@ -105,20 +103,9 @@ import type {
WidgetControlProps,
} from '@staticcms/core/interface';
import type { AnyObject, AutoformatPlugin, PlatePlugin } from '@udecode/plate';
import type { CSSProperties, FC } from 'react';
import type { FC } from 'react';
import type { MdEditor, MdValue } from './plateTypes';
const StyledPlateEditor = styled('div')`
position: relative;
padding: 1.25rem;
padding-bottom: 0;
margin-bottom: 1.25rem;
`;
const styles: Record<string, CSSProperties> = {
container: { position: 'relative' },
};
export interface PlateEditorProps {
initialValue: MdValue;
collection: Collection<MarkdownField>;
@ -142,10 +129,11 @@ const PlateEditor: FC<PlateEditorProps> = ({
onFocus,
onBlur,
}) => {
const outerEditorContainerRef = useRef<HTMLDivElement | null>(null);
const editorContainerRef = useRef<HTMLDivElement | null>(null);
const innerEditorContainerRef = useRef<HTMLDivElement | null>(null);
const { disabled } = controlProps;
const components = useMemo(() => {
const baseComponents = {
[ELEMENT_H1]: Heading1,
@ -162,13 +150,10 @@ const PlateEditor: FC<PlateEditorProps> = ({
[ELEMENT_BLOCKQUOTE]: BlockquoteElement,
[ELEMENT_CODE_BLOCK]: CodeBlockElement,
[ELEMENT_LINK]: withLinkElement({
containerRef: innerEditorContainerRef.current,
collection,
entry,
field,
}),
[ELEMENT_IMAGE]: withImageElement({
containerRef: innerEditorContainerRef.current,
collection,
entry,
field,
@ -268,7 +253,7 @@ const PlateEditor: FC<PlateEditorProps> = ({
return useMemo(
() => (
<StyledPlateEditor>
<div className="relative px-3 py-5 pb-0 mb-5">
<DndProvider backend={HTML5Backend}>
<PlateProvider<MdValue>
id={id}
@ -276,18 +261,18 @@ const PlateEditor: FC<PlateEditorProps> = ({
initialValue={initialValue}
plugins={plugins}
onChange={onChange}
readOnly={disabled}
>
<div key="editor-outer_wrapper" ref={outerEditorContainerRef} style={styles.container}>
<div key="editor-outer_wrapper">
<Toolbar
key="toolbar"
useMdx={useMdx}
containerRef={outerEditorContainerRef.current}
collection={collection}
field={field}
entry={entry}
disabled={disabled}
/>
<div key="editor-wrapper" ref={editorContainerRef} style={styles.container}>
<div key="editor-wrapper" ref={editorContainerRef} className="w-full overflow-hidden">
<Plate
key="editor"
id={id}
@ -297,18 +282,14 @@ const PlateEditor: FC<PlateEditorProps> = ({
onBlur,
}}
>
<div
key="editor-inner-wrapper"
ref={innerEditorContainerRef}
style={styles.container}
>
<div key="editor-inner-wrapper" ref={innerEditorContainerRef}>
<BalloonToolbar
key="balloon-toolbar"
useMdx={useMdx}
containerRef={innerEditorContainerRef.current}
collection={collection}
field={field}
entry={entry}
disabled={disabled}
/>
<CursorOverlayContainer containerRef={editorContainerRef} />
</div>
@ -317,7 +298,7 @@ const PlateEditor: FC<PlateEditorProps> = ({
</div>
</PlateProvider>
</DndProvider>
</StyledPlateEditor>
</div>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[collection, field, onBlur, onFocus, initialValue, onChange, plugins],

View File

@ -1,6 +1,4 @@
import Box from '@mui/material/Box';
import Popper from '@mui/material/Popper';
import { styled } from '@mui/material/styles';
import PopperUnstyled from '@mui/base/PopperUnstyled';
import {
ELEMENT_LINK,
ELEMENT_TD,
@ -29,39 +27,16 @@ import MediaToolbarButtons from '../buttons/MediaToolbarButtons';
import ShortcodeToolbarButton from '../buttons/ShortcodeToolbarButton';
import TableToolbarButtons from '../buttons/TableToolbarButtons';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
import type { Collection, MarkdownField } from '@staticcms/core/interface';
import type { ClientRectObject } from '@udecode/plate';
import type { FC, ReactNode } from 'react';
const StyledPopperContent = styled('div')(
({ theme }) => `
display: flex;
gap: 4px;
background: ${theme.palette.background.paper};
box-shadow: ${theme.shadows[8]};
margin-bottom: 10px;
padding: 6px;
border-radius: 4px;
align-items: center;
`,
);
const StyledDivider = styled('div')(
({ theme }) => `
height: 18px;
width: 1px;
background: ${theme.palette.text.secondary};
margin: 0 4px;
opacity: 0.5;
`,
);
export interface BalloonToolbarProps {
useMdx: boolean;
containerRef: HTMLElement | null;
collection: Collection<MarkdownField>;
field: MarkdownField;
entry: Entry;
disabled: boolean;
}
const BalloonToolbar: FC<BalloonToolbarProps> = ({
@ -69,7 +44,7 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
containerRef,
collection,
field,
entry,
disabled,
}) => {
const hasEditorFocus = useFocused();
const editor = useMdPlateEditorState();
@ -77,13 +52,6 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
const [hasFocus, setHasFocus] = useState(false);
const debouncedHasFocus = useDebounce(hasFocus, 150);
const [childFocusState, setChildFocusState] = useState<Record<string, boolean>>({});
const childHasFocus = useMemo(
() => Object.keys(childFocusState).reduce((acc, value) => acc || childFocusState[value], false),
[childFocusState],
);
const debouncedChildHasFocus = useDebounce(hasFocus, 150);
const handleFocus = useCallback(() => {
setHasFocus(true);
}, []);
@ -92,32 +60,10 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
setHasFocus(false);
}, []);
const handleChildFocus = useCallback(
(key: string) => () => {
setChildFocusState(oldState => ({
...oldState,
[key]: true,
}));
},
[],
);
const handleChildBlur = useCallback(
(key: string) => () => {
setChildFocusState(oldState => ({
...oldState,
[key]: false,
}));
},
[],
);
const anchorEl = useRef<HTMLDivElement>();
const anchorEl = useRef<HTMLDivElement | null>(null);
const [selectionBoundingClientRect, setSelectionBoundingClientRect] =
useState<ClientRectObject | null>(null);
const [mediaOpen, setMediaOpen] = useState(false);
const [selectionExpanded, selectionText] = useMemo(() => {
if (!editor) {
return [undefined, undefined, undefined];
@ -149,14 +95,7 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
const debouncedEditorFocus = useDebounce(hasEditorFocus, 150);
const groups: ReactNode[] = useMemo(() => {
if (
!mediaOpen &&
!debouncedEditorFocus &&
!hasFocus &&
!debouncedHasFocus &&
!debouncedChildHasFocus &&
!childHasFocus
) {
if (!debouncedEditorFocus && !hasFocus && !debouncedHasFocus) {
return [];
}
@ -167,27 +106,60 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
// Selected text buttons
if (selectionText && selectionExpanded) {
return [
<BasicMarkToolbarButtons key="selection-basic-mark-buttons" useMdx={useMdx} />,
<BasicMarkToolbarButtons
key="selection-basic-mark-buttons"
useMdx={useMdx}
disabled={disabled}
/>,
<BasicElementToolbarButtons
key="selection-basic-element-buttons"
hideFontTypeSelect={isInTableCell}
hideCodeBlock
disabled={disabled}
/>,
isInTableCell && <TableToolbarButtons key="selection-table-toolbar-buttons" />,
isInTableCell && (
<TableToolbarButtons key="selection-table-toolbar-buttons" disabled={disabled} />
),
<MediaToolbarButtons
key="selection-media-buttons"
containerRef={containerRef}
collection={collection}
field={field}
entry={entry}
onMediaToggle={setMediaOpen}
hideImages
handleChildFocus={handleChildFocus}
handleChildBlur={handleChildBlur}
disabled={disabled}
/>,
].filter(Boolean);
}
const allButtons = [
<BasicMarkToolbarButtons
key="empty-basic-mark-buttons"
useMdx={useMdx}
disabled={disabled}
/>,
<BasicElementToolbarButtons
key="empty-basic-element-buttons"
hideFontTypeSelect={isInTableCell}
hideCodeBlock
disabled={disabled}
/>,
<TableToolbarButtons
key="empty-table-toolbar-buttons"
isInTable={isInTableCell}
disabled={disabled}
/>,
<MediaToolbarButtons
key="empty-media-buttons"
collection={collection}
field={field}
disabled={disabled}
/>,
!useMdx ? <ShortcodeToolbarButton key="shortcode-button" disabled={disabled} /> : null,
].filter(Boolean);
// if (isInTableCell) {
// return allButtons;
// }
// Empty paragraph, not first line
if (
editor.children.length > 1 &&
@ -205,33 +177,13 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
!VOID_ELEMENTS.includes(parent[0].type as string) &&
parent[0].children.length === 1
) {
return [
<BasicMarkToolbarButtons key="empty-basic-mark-buttons" useMdx={useMdx} />,
<BasicElementToolbarButtons
key="empty-basic-element-buttons"
hideFontTypeSelect={isInTableCell}
hideCodeBlock
/>,
<TableToolbarButtons key="empty-table-toolbar-buttons" isInTable={isInTableCell} />,
<MediaToolbarButtons
key="empty-media-buttons"
containerRef={containerRef}
collection={collection}
field={field}
entry={entry}
onMediaToggle={setMediaOpen}
handleChildFocus={handleChildFocus}
handleChildBlur={handleChildBlur}
/>,
!useMdx ? <ShortcodeToolbarButton key="shortcode-button" /> : null,
].filter(Boolean);
return allButtons;
}
}
return [];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
mediaOpen,
debouncedEditorFocus,
hasFocus,
debouncedHasFocus,
@ -270,31 +222,50 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
return (
<>
<Box
<div
ref={anchorEl}
sx={{
position: 'fixed',
top: selectionBoundingClientRect?.y,
left: selectionBoundingClientRect?.x,
className="fixed"
style={{
top: `${selectionBoundingClientRect?.y ?? 0}px`,
left: `${selectionBoundingClientRect?.x}px`,
}}
/>
<Popper
<PopperUnstyled
open={Boolean(debouncedOpen && anchorEl.current)}
component="div"
placement="top"
anchorEl={anchorEl.current ?? null}
sx={{ zIndex: 100 }}
onFocus={handleFocus}
onBlur={handleBlur}
disablePortal
tabIndex={0}
className="
absolute
max-h-60
overflow-auto
rounded-md
bg-white
p-1
text-base
shadow-lg
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-40
dark:bg-slate-700
"
>
<StyledPopperContent>
{(groups.length > 0 ? groups : debouncedGroups).map((group, index) => [
index !== 0 ? <StyledDivider key={`balloon-toolbar-divider-${index}`} /> : null,
group,
])}
</StyledPopperContent>
</Popper>
<div
className="
flex
gap-0.5
"
>
{groups.length > 0 ? groups : debouncedGroups}
</div>
</PopperUnstyled>
</>
);
};

View File

@ -1,7 +1,8 @@
/**
* @jest-environment jsdom
*/
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { screen } from '@testing-library/react';
import {
findNodePath,
getNode,
@ -13,17 +14,19 @@ import {
} from '@udecode/plate';
import React, { useRef } from 'react';
import { useFocused } from 'slate-react';
import '@testing-library/jest-dom';
import { mockMarkdownCollection, mockMarkdownField } from '@staticcms/test/data/collection';
import { configLoaded } from '@staticcms/core/actions/config';
import { store } from '@staticcms/core/store';
import { createMockCollection } from '@staticcms/test/data/collections.mock';
import { createMockConfig } from '@staticcms/test/data/config.mock';
import { mockMarkdownField } from '@staticcms/test/data/fields.mock';
import { renderWithProviders } from '@staticcms/test/test-utils';
import BalloonToolbar from '../BalloonToolbar';
import type { Entry } from '@staticcms/core/interface';
import type { Config, MarkdownField } from '@staticcms/core/interface';
import type { MdEditor } from '@staticcms/markdown/plate/plateTypes';
import type { FC } from 'react';
let entry: Entry;
interface BalloonToolbarWrapperProps {
useMdx?: boolean;
}
@ -37,14 +40,18 @@ const BalloonToolbarWrapper: FC<BalloonToolbarWrapperProps> = ({ useMdx = false
key="balloon-toolbar"
useMdx={useMdx}
containerRef={ref.current}
collection={mockMarkdownCollection}
collection={createMockCollection({}, mockMarkdownField)}
field={mockMarkdownField}
entry={entry}
disabled={false}
/>
</div>
);
};
const config = createMockConfig({
collections: [],
}) as unknown as Config<MarkdownField>;
describe(BalloonToolbar.name, () => {
const mockUseEditor = usePlateEditorState as jest.Mock;
let mockEditor: MdEditor;
@ -58,25 +65,27 @@ describe(BalloonToolbar.name, () => {
const mockGetParentNode = getParentNode as jest.Mock;
beforeEach(() => {
entry = {
collection: 'posts',
slug: '2022-12-13-post-number-1',
path: '_posts/2022-12-13-post-number-1.md',
partial: false,
raw: '--- title: "This is post # 1" draft: false date: 2022-12-13T00:00:00.000Z --- # The post is number 1\n\nAnd some text',
label: '',
author: '',
mediaFiles: [],
isModification: null,
newRecord: false,
updatedOn: '',
data: {
title: 'This is post # 1',
draft: false,
date: '2022-12-13T00:00:00.000Z',
body: '# The post is number 1\n\nAnd some text',
},
};
store.dispatch(configLoaded(config as unknown as Config));
// entry = {
// collection: 'posts',
// slug: '2022-12-13-post-number-1',
// path: '_posts/2022-12-13-post-number-1.md',
// partial: false,
// raw: '--- title: "This is post # 1" draft: false date: 2022-12-13T00:00:00.000Z --- # The post is number 1\n\nAnd some text',
// label: '',
// author: '',
// mediaFiles: [],
// isModification: null,
// newRecord: false,
// updatedOn: '',
// data: {
// title: 'This is post # 1',
// draft: false,
// date: '2022-12-13T00:00:00.000Z',
// body: '# The post is number 1\n\nAnd some text',
// },
// };
mockEditor = {
selection: undefined,
@ -86,7 +95,7 @@ describe(BalloonToolbar.name, () => {
});
it('renders empty div by default', () => {
render(<BalloonToolbarWrapper />);
renderWithProviders(<BalloonToolbarWrapper />);
expect(screen.queryAllByRole('button').length).toBe(0);
});
@ -126,7 +135,7 @@ describe(BalloonToolbar.name, () => {
},
]);
const { rerender } = render(<BalloonToolbarWrapper />);
const { rerender } = renderWithProviders(<BalloonToolbarWrapper />);
rerender(<BalloonToolbarWrapper useMdx={useMdx} />);
};
@ -139,7 +148,6 @@ describe(BalloonToolbar.name, () => {
expect(screen.queryByTestId('toolbar-button-code')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-strikethrough')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-blockquote')).toBeInTheDocument();
expect(screen.queryByTestId('font-type-select')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-add-table')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-insert-link')).toBeInTheDocument();
@ -157,7 +165,6 @@ describe(BalloonToolbar.name, () => {
expect(screen.queryByTestId('toolbar-button-code')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-strikethrough')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-blockquote')).toBeInTheDocument();
expect(screen.queryByTestId('font-type-select')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-add-table')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-insert-link')).toBeInTheDocument();

View File

@ -0,0 +1,97 @@
import { Add as AddIcon } from '@styled-icons/material/Add';
import { Code as CodeIcon } from '@styled-icons/material/Code';
import { FormatQuote as FormatQuoteIcon } from '@styled-icons/material/FormatQuote';
import {
ELEMENT_BLOCKQUOTE,
ELEMENT_CODE_BLOCK,
ELEMENT_IMAGE,
ELEMENT_LINK,
insertEmptyCodeBlock,
toggleNodeType,
} from '@udecode/plate';
import React, { useCallback } from 'react';
import Menu from '@staticcms/core/components/common/menu/Menu';
import MenuGroup from '@staticcms/core/components/common/menu/MenuGroup';
import MenuItemButton from '@staticcms/core/components/common/menu/MenuItemButton';
import { useMdPlateEditorState } from '../../plateTypes';
import ImageToolbarButton from './common/ImageToolbarButton';
import LinkToolbarButton from './common/LinkToolbarButton';
import type { Collection, MarkdownField } from '@staticcms/core/interface';
import type { FC } from 'react';
interface AddButtonsProps {
collection: Collection<MarkdownField>;
field: MarkdownField;
disabled: boolean;
}
const AddButtons: FC<AddButtonsProps> = ({ collection, field, disabled }) => {
const editor = useMdPlateEditorState();
const handleBlockOnClick = useCallback(
(type: string, inactiveType?: string) => () => {
toggleNodeType(editor, { activeType: type, inactiveType });
},
[editor],
);
const handleCodeBlockOnClick = useCallback(() => {
insertEmptyCodeBlock(editor, {
insertNodesOptions: { select: true },
});
}, [editor]);
return (
<Menu
label={<AddIcon className="h-5 w-5" aria-hidden="true" />}
data-testid="toolbar-add-buttons"
keepMounted
hideDropdownIcon
variant="text"
className="
py-0.5
px-0.5
h-7
w-7
"
disabled={disabled}
>
<MenuGroup>
<MenuItemButton
key={ELEMENT_BLOCKQUOTE}
onClick={handleBlockOnClick(ELEMENT_BLOCKQUOTE)}
startIcon={FormatQuoteIcon}
>
Blockquote
</MenuItemButton>
<MenuItemButton
key={ELEMENT_CODE_BLOCK}
onClick={handleCodeBlockOnClick}
startIcon={CodeIcon}
>
Code Block
</MenuItemButton>
</MenuGroup>
<MenuGroup>
<ImageToolbarButton
key={ELEMENT_IMAGE}
collection={collection}
field={field}
variant="menu"
disabled={disabled}
/>
<LinkToolbarButton
key={ELEMENT_LINK}
collection={collection}
field={field}
variant="menu"
disabled={disabled}
/>
</MenuGroup>
</Menu>
);
};
export default AddButtons;

View File

@ -1,32 +1,39 @@
import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter';
import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft';
import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight';
import { FormatAlignCenter as FormatAlignCenterIcon } from '@styled-icons/material/FormatAlignCenter';
import { FormatAlignLeft as FormatAlignLeftIcon } from '@styled-icons/material/FormatAlignLeft';
import { FormatAlignRight as FormatAlignRightIcon } from '@styled-icons/material/FormatAlignRight';
import React from 'react';
import AlignToolbarButton from './common/AlignToolbarButton';
import type { FC } from 'react';
const AlignToolbarButtons: FC = () => {
interface AlignToolbarButtonsProps {
disabled: boolean;
}
const AlignToolbarButtons: FC<AlignToolbarButtonsProps> = ({ disabled }) => {
return (
<>
<AlignToolbarButton
key="algin-button-left"
tooltip="Align Left"
value="left"
icon={<FormatAlignLeftIcon />}
icon={<FormatAlignLeftIcon className="h-5 w-5" />}
disabled={disabled}
/>
<AlignToolbarButton
key="algin-button-center"
tooltip="Align Center"
value="center"
icon={<FormatAlignCenterIcon />}
icon={<FormatAlignCenterIcon className="h-5 w-5" />}
disabled={disabled}
/>
<AlignToolbarButton
key="algin-button-right"
tooltip="Align Right"
value="right"
icon={<FormatAlignRightIcon />}
icon={<FormatAlignRightIcon className="h-5 w-5" />}
disabled={disabled}
/>
</>
);

View File

@ -1,9 +1,5 @@
import CodeIcon from '@mui/icons-material/Code';
import FormatQuoteIcon from '@mui/icons-material/FormatQuote';
import { ELEMENT_BLOCKQUOTE, ELEMENT_CODE_BLOCK, insertEmptyCodeBlock } from '@udecode/plate';
import React from 'react';
import BlockToolbarButton from './common/BlockToolbarButton';
import FontTypeSelect from './FontTypeSelect';
import type { FC } from 'react';
@ -12,35 +8,17 @@ export interface BasicElementToolbarButtonsProps {
hideFontTypeSelect?: boolean;
disableFontTypeSelect?: boolean;
hideCodeBlock?: boolean;
disabled: boolean;
}
const BasicElementToolbarButtons: FC<BasicElementToolbarButtonsProps> = ({
hideFontTypeSelect = false,
disableFontTypeSelect = false,
hideCodeBlock = false,
disabled,
}) => {
return (
<>
{!hideFontTypeSelect ? <FontTypeSelect disabled={disableFontTypeSelect} /> : null}
<BlockToolbarButton
tooltip="Blockquote"
type={ELEMENT_BLOCKQUOTE}
icon={<FormatQuoteIcon />}
/>
{!hideCodeBlock ? (
<BlockToolbarButton
tooltip="Code Block"
type={ELEMENT_CODE_BLOCK}
icon={<CodeIcon />}
onClick={editor =>
insertEmptyCodeBlock(editor, {
insertNodesOptions: { select: true },
})
}
/>
) : null}
</>
);
return !hideFontTypeSelect ? (
<FontTypeSelect disabled={disableFontTypeSelect || disabled} />
) : null;
};
export default BasicElementToolbarButtons;

View File

@ -1,10 +1,10 @@
import CodeIcon from '@mui/icons-material/Code';
import FormatBoldIcon from '@mui/icons-material/FormatBold';
import FormatItalicIcon from '@mui/icons-material/FormatItalic';
import FormatStrikethroughIcon from '@mui/icons-material/FormatStrikethrough';
import FormatUnderlinedIcon from '@mui/icons-material/FormatUnderlined';
import SubscriptIcon from '@mui/icons-material/Subscript';
import SuperscriptIcon from '@mui/icons-material/Superscript';
import { Code as CodeIcon } from '@styled-icons/material/Code';
import { FormatBold as FormatBoldIcon } from '@styled-icons/material/FormatBold';
import { FormatItalic as FormatItalicIcon } from '@styled-icons/material/FormatItalic';
import { FormatStrikethrough as FormatStrikethroughIcon } from '@styled-icons/material/FormatStrikethrough';
import { FormatUnderlined as FormatUnderlinedIcon } from '@styled-icons/material/FormatUnderlined';
import { Subscript as SubscriptIcon } from '@styled-icons/material/Subscript';
import { Superscript as SuperscriptIcon } from '@styled-icons/material/Superscript';
import {
MARK_BOLD,
MARK_CODE,
@ -23,30 +23,49 @@ import type { FC } from 'react';
export interface BasicMarkToolbarButtonsProps {
extended?: boolean;
useMdx: boolean;
disabled: boolean;
}
const BasicMarkToolbarButtons: FC<BasicMarkToolbarButtonsProps> = ({
extended = false,
useMdx,
disabled,
}) => {
return (
<>
<MarkToolbarButton tooltip="Bold" type={MARK_BOLD} icon={<FormatBoldIcon />} />
<MarkToolbarButton tooltip="Italic" type={MARK_ITALIC} icon={<FormatItalicIcon />} />
<MarkToolbarButton
tooltip="Bold"
type={MARK_BOLD}
icon={<FormatBoldIcon className="h-5 w-5" />}
disabled={disabled}
/>
<MarkToolbarButton
tooltip="Italic"
type={MARK_ITALIC}
icon={<FormatItalicIcon className="h-5 w-5" />}
disabled={disabled}
/>
{useMdx ? (
<MarkToolbarButton
key="underline-button"
tooltip="Underline"
type={MARK_UNDERLINE}
icon={<FormatUnderlinedIcon />}
icon={<FormatUnderlinedIcon className="h-5 w-5" />}
disabled={disabled}
/>
) : null}
<MarkToolbarButton
tooltip="Strikethrough"
type={MARK_STRIKETHROUGH}
icon={<FormatStrikethroughIcon />}
icon={<FormatStrikethroughIcon className="h-5 w-5" />}
disabled={disabled}
/>
<MarkToolbarButton
tooltip="Code"
type={MARK_CODE}
icon={<CodeIcon className="h-5 w-5" />}
disabled={disabled}
/>
<MarkToolbarButton tooltip="Code" type={MARK_CODE} icon={<CodeIcon />} />
{useMdx && extended ? (
<>
<MarkToolbarButton
@ -54,14 +73,16 @@ const BasicMarkToolbarButtons: FC<BasicMarkToolbarButtonsProps> = ({
tooltip="Superscript"
type={MARK_SUPERSCRIPT}
clear={MARK_SUBSCRIPT}
icon={<SuperscriptIcon />}
icon={<SuperscriptIcon className="h-5 w-5" />}
disabled={disabled}
/>
<MarkToolbarButton
key="subscript-button"
tooltip="Subscript"
type={MARK_SUBSCRIPT}
clear={MARK_SUPERSCRIPT}
icon={<SubscriptIcon />}
icon={<SubscriptIcon className="h-5 w-5" />}
disabled={disabled}
/>
</>
) : null}

View File

@ -1,5 +1,5 @@
import FontDownloadIcon from '@mui/icons-material/FontDownload';
import FormatColorTextIcon from '@mui/icons-material/FormatColorText';
import { FontDownload as FontDownloadIcon } from '@styled-icons/material/FontDownload';
import { FormatColorText as FormatColorTextIcon } from '@styled-icons/material/FormatColorText';
import { MARK_BG_COLOR, MARK_COLOR } from '@udecode/plate';
import React from 'react';
@ -7,20 +7,26 @@ import ColorPickerToolbarDropdown from './common/ColorPickerToolbarDropdown';
import type { FC } from 'react';
const ColorToolbarButtons: FC = () => {
interface ColorToolbarButtonsProps {
disabled: boolean;
}
const ColorToolbarButtons: FC<ColorToolbarButtonsProps> = ({ disabled }) => {
return (
<>
<ColorPickerToolbarDropdown
key="color-picker-button"
pluginKey={MARK_COLOR}
icon={<FormatColorTextIcon />}
icon={<FormatColorTextIcon className="h-5 w-5" />}
tooltip="Color"
disabled={disabled}
/>
<ColorPickerToolbarDropdown
key="background-color-picker-button"
pluginKey={MARK_BG_COLOR}
icon={<FontDownloadIcon />}
icon={<FontDownloadIcon className="h-5 w-5" />}
tooltip="Background Color"
disabled={disabled}
/>
</>
);

View File

@ -1,7 +1,5 @@
import FormControl from '@mui/material/FormControl';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import { styled } from '@mui/material/styles';
import OptionUnstyled from '@mui/base/OptionUnstyled';
import SelectUnstyled from '@mui/base/SelectUnstyled';
import {
ELEMENT_H1,
ELEMENT_H2,
@ -16,46 +14,43 @@ import { someNode, toggleNodeType } from '@udecode/plate-core';
import React, { useCallback, useMemo, useState } from 'react';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import type { SelectChangeEvent } from '@mui/material/Select';
import type { FC } from 'react';
import type { FC, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
const StyledSelect = styled(Select<string>)`
padding: 0;
type Option = {
value: string;
label: string;
};
& .MuiSelect-select {
padding: 4px 7px;
}
`;
const types = [
const types: Option[] = [
{
type: ELEMENT_H1,
value: ELEMENT_H1,
label: 'Heading 1',
},
{
type: ELEMENT_H2,
value: ELEMENT_H2,
label: 'Heading 2',
},
{
type: ELEMENT_H3,
value: ELEMENT_H3,
label: 'Heading 3',
},
{
type: ELEMENT_H4,
value: ELEMENT_H4,
label: 'Heading 4',
},
{
type: ELEMENT_H5,
value: ELEMENT_H5,
label: 'Heading 5',
},
{
type: ELEMENT_H6,
value: ELEMENT_H6,
label: 'Heading 6',
},
{
type: ELEMENT_PARAGRAPH,
value: ELEMENT_PARAGRAPH,
label: 'Paragraph',
},
];
@ -76,21 +71,19 @@ const FontTypeSelect: FC<FontTypeSelectProps> = ({ disabled = false }) => {
const value = useMemo(() => {
return (
selection &&
types.find(type => someNode(editor, { match: { type: type.type }, at: selection?.anchor }))
types.find(type => someNode(editor, { match: { type: type.value }, at: selection?.anchor }))
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, selection, version]);
const handleChange = useCallback(
(event: SelectChangeEvent<string>) => {
event.preventDefault();
if (value?.type === event.target.value) {
(_event: KeyboardEvent | FocusEvent | MouseEvent | null, newValue: string | null) => {
if (!newValue || value?.value === newValue) {
return;
}
toggleNodeType(editor, {
activeType: event.target.value,
activeType: newValue,
});
setVersion(oldVersion => oldVersion + 1);
@ -99,27 +92,111 @@ const FontTypeSelect: FC<FontTypeSelectProps> = ({ disabled = false }) => {
focusEditor(editor);
});
},
[editor, value?.type],
[editor, value?.value],
);
return (
<FormControl sx={{ width: 120 }}>
<StyledSelect
labelId="font-type-select-label"
id="font-type-select"
data-testid="font-type-select"
value={value?.type ?? ELEMENT_PARAGRAPH}
<div
className="
w-28
h-6
mx-1
"
>
<SelectUnstyled
value={value?.value ?? ELEMENT_PARAGRAPH}
onChange={handleChange}
size="small"
disabled={disabled}
slotProps={{
root: {
className: classNames(
`
flex
items-center
text-sm
font-medium
relative
px-1.5
py-0.5
w-full
h-6
border
rounded-sm
`,
disabled
? `
text-gray-300
border-gray-200
dark:border-gray-600
dark:text-gray-500
`
: `
text-gray-900
border-gray-600
dark:border-gray-200
dark:text-gray-100
`,
),
},
popper: {
disablePortal: false,
className: `
max-h-40
w-50
overflow-auto
rounded-md
bg-white
shadow-lg
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
dark:bg-slate-800
`,
},
}}
data-testid="font-type-select"
>
{types.map(type => (
<MenuItem key={type.type} value={type.type}>
{type.label}
</MenuItem>
))}
</StyledSelect>
</FormControl>
{types.map(type => {
const selected = (value?.value ?? ELEMENT_PARAGRAPH) === type.value;
return (
<OptionUnstyled
key={type.value}
value={type.value}
slotProps={{
root: {
className: classNames(
`
relative
select-none
py-2
px-4
cursor-pointer
hover:bg-blue-500
hover:text-white
dark:hover:bg-blue-500
`,
selected &&
`
bg-blue-500/25
dark:bg-blue-700/20
`,
),
},
}}
>
<span
className={classNames('block truncate', selected ? 'font-medium' : 'font-normal')}
>
{type.label}
</span>
</OptionUnstyled>
);
})}
</SelectUnstyled>
</div>
);
};

View File

@ -1,7 +1,7 @@
import FormatIndentDecreaseIcon from '@mui/icons-material/FormatIndentDecrease';
import FormatIndentIncreaseIcon from '@mui/icons-material/FormatIndentIncrease';
import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
import { FormatIndentDecrease as FormatIndentDecreaseIcon } from '@styled-icons/material/FormatIndentDecrease';
import { FormatIndentIncrease as FormatIndentIncreaseIcon } from '@styled-icons/material/FormatIndentIncrease';
import { FormatListBulleted as FormatListBulletedIcon } from '@styled-icons/material/FormatListBulleted';
import { FormatListNumbered as FormatListNumberedIcon } from '@styled-icons/material/FormatListNumbered';
import { ELEMENT_OL, ELEMENT_UL, getPluginType, indent, outdent } from '@udecode/plate';
import React, { useCallback } from 'react';
@ -12,7 +12,11 @@ import ToolbarButton from './common/ToolbarButton';
import type { FC } from 'react';
import type { MdEditor } from '@staticcms/markdown';
const ListToolbarButtons: FC = () => {
interface ListToolbarButtonsProps {
disabled: boolean;
}
const ListToolbarButtons: FC<ListToolbarButtonsProps> = ({ disabled }) => {
const editor = useMdPlateEditorRef();
const handleOutdent = useCallback((editor: MdEditor) => {
@ -25,18 +29,30 @@ const ListToolbarButtons: FC = () => {
return (
<>
<ListToolbarButton tooltip="List" type={ELEMENT_UL} icon={<FormatListBulletedIcon />} />
<ListToolbarButton
tooltip="List"
type={ELEMENT_UL}
icon={<FormatListBulletedIcon className="h-5 w-5" />}
disabled={disabled}
/>
<ListToolbarButton
tooltip="Numbered List"
type={getPluginType(editor, ELEMENT_OL)}
icon={<FormatListNumberedIcon />}
icon={<FormatListNumberedIcon className="h-5 w-5" />}
disabled={disabled}
/>
<ToolbarButton
tooltip="Outdent"
onClick={handleOutdent}
icon={<FormatIndentDecreaseIcon />}
icon={<FormatIndentDecreaseIcon className="h-5 w-5" />}
disabled={disabled}
/>
<ToolbarButton
tooltip="Indent"
onClick={handleIndent}
icon={<FormatIndentIncreaseIcon className="h-5 w-5" />}
disabled={disabled}
/>
<ToolbarButton tooltip="Indent" onClick={handleIndent} icon={<FormatIndentIncreaseIcon />} />
</>
);
};

View File

@ -1,81 +1,38 @@
import ImageIcon from '@mui/icons-material/Image';
import LinkIcon from '@mui/icons-material/Link';
import React, { useEffect, useState } from 'react';
import React from 'react';
import ImageToolbarButton from './common/ImageToolbarButton';
import LinkToolbarButton from './common/LinkToolbarButton';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
import type { Collection, MarkdownField } from '@staticcms/core/interface';
import type { FC } from 'react';
export interface MediaToolbarButtonsProps {
containerRef: HTMLElement | null;
hideImages?: boolean;
collection: Collection<MarkdownField>;
field: MarkdownField;
entry: Entry;
inserting?: boolean;
onMediaToggle?: (open: boolean) => void;
handleChildFocus?: (key: string) => () => void;
handleChildBlur?: (key: string) => () => void;
hideImages?: boolean;
disabled: boolean;
}
const MediaToolbarButtons: FC<MediaToolbarButtonsProps> = ({
containerRef,
collection,
field,
entry,
hideImages = false,
onMediaToggle,
handleChildFocus,
handleChildBlur,
disabled,
}) => {
const [open, setOpen] = useState(false);
const [linkMediaOpen, setLinkMediaOpen] = useState(false);
const [imageMediaOpen, setImageMediaOpen] = useState(false);
useEffect(() => {
if (open && !linkMediaOpen && !imageMediaOpen) {
setOpen(false);
onMediaToggle?.(false);
return;
}
if (!open && (linkMediaOpen || imageMediaOpen)) {
setOpen(true);
onMediaToggle?.(true);
return;
}
}, [imageMediaOpen, linkMediaOpen, onMediaToggle, open]);
return (
<>
<LinkToolbarButton
containerRef={containerRef}
tooltip="Insert Link"
key="link-button"
icon={<LinkIcon />}
collection={collection}
field={field}
entry={entry}
mediaOpen={linkMediaOpen}
onMediaToggle={setLinkMediaOpen}
onFocus={handleChildFocus?.('link')}
onBlur={handleChildBlur?.('link')}
disabled={disabled}
/>
{!hideImages ? (
<ImageToolbarButton
containerRef={containerRef}
tooltip="Insert Image"
key="image-button"
icon={<ImageIcon />}
collection={collection}
field={field}
entry={entry}
mediaOpen={imageMediaOpen}
onMediaToggle={setImageMediaOpen}
onFocus={handleChildFocus?.('image')}
onBlur={handleChildBlur?.('image')}
disabled={disabled}
/>
) : null}
</>

View File

@ -1,28 +1,22 @@
import DataArrayIcon from '@mui/icons-material/DataArray';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { DataArray as DataArrayIcon } from '@styled-icons/material/DataArray';
import { focusEditor, insertNodes } from '@udecode/plate-core';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useMemo } from 'react';
import { getShortcodes } from '../../../../../lib/registry';
import { toTitleCase } from '../../../../../lib/util/string.util';
import Menu from '@staticcms/core/components/common/menu/Menu';
import MenuGroup from '@staticcms/core/components/common/menu/MenuGroup';
import MenuItemButton from '@staticcms/core/components/common/menu/MenuItemButton';
import { getShortcodes } from '@staticcms/core/lib/registry';
import { toTitleCase } from '@staticcms/core/lib/util/string.util';
import { ELEMENT_SHORTCODE, useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import ToolbarButton from './common/ToolbarButton';
import type { FC, MouseEvent } from 'react';
import type { MdEditor } from '../../plateTypes';
import type { FC } from 'react';
const ShortcodeToolbarButton: FC = () => {
interface ShortcodeToolbarButtonProps {
disabled: boolean;
}
const ShortcodeToolbarButton: FC<ShortcodeToolbarButtonProps> = ({ disabled }) => {
const editor = useMdPlateEditorState();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((_editor: MdEditor, event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const configs = useMemo(() => getShortcodes(), []);
@ -35,38 +29,36 @@ const ShortcodeToolbarButton: FC = () => {
children: [{ text: '' }],
});
focusEditor(editor);
handleClose();
},
[editor, handleClose],
[editor],
);
return (
<>
<ToolbarButton
key="shortcode-button"
tooltip="Add Shortcode"
icon={<DataArrayIcon />}
onClick={handleClick}
/>
<Menu
id="shortcode-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'shortcode-button',
}}
>
<Menu
label={<DataArrayIcon className="h-5 w-5" aria-hidden="true" />}
data-testid="add-buttons"
keepMounted
hideDropdownIcon
variant="text"
className="
py-0.5
px-0.5
h-6
w-6
"
disabled={disabled}
>
<MenuGroup>
{Object.keys(configs).map(name => {
const config = configs[name];
return (
<MenuItem key={`shortcode-${name}`} onClick={handleShortcodeClick(name)}>
<MenuItemButton key={`shortcode-${name}`} onClick={handleShortcodeClick(name)}>
{config.label ?? toTitleCase(name)}
</MenuItem>
</MenuItemButton>
);
})}
</Menu>
</>
</MenuGroup>
</Menu>
);
};

View File

@ -21,9 +21,10 @@ import type { MdEditor } from '@staticcms/markdown';
export interface TableToolbarButtonsProps {
isInTable?: boolean;
disabled: boolean;
}
const TableToolbarButtons: FC<TableToolbarButtonsProps> = ({ isInTable = true }) => {
const TableToolbarButtons: FC<TableToolbarButtonsProps> = ({ isInTable = true, disabled }) => {
const handleTableAdd = useCallback((editor: MdEditor) => {
insertTable(editor, {
rowCount: 2,
@ -56,40 +57,46 @@ const TableToolbarButtons: FC<TableToolbarButtonsProps> = ({ isInTable = true })
<ToolbarButton
key="insertRow"
tooltip="Insert Row"
icon={<TableInsertRow />}
icon={<TableInsertRow className="w-5 h-5" />}
onClick={handleInsertTableRow}
disabled={disabled}
/>
<ToolbarButton
key="deleteRow"
tooltip="Delete Row"
icon={<TableDeleteRow />}
icon={<TableDeleteRow className="w-5 h-5" />}
onClick={handleDeleteRow}
disabled={disabled}
/>
<ToolbarButton
key="insertColumn"
tooltip="Insert Column"
icon={<TableInsertColumn />}
icon={<TableInsertColumn className="w-5 h-5" />}
onClick={handleInsertTableColumn}
disabled={disabled}
/>
<ToolbarButton
key="deleteColumn"
tooltip="Delete Column"
icon={<TableDeleteColumn />}
icon={<TableDeleteColumn className="w-5 h-5" />}
onClick={handleDeleteColumn}
disabled={disabled}
/>
<ToolbarButton
key="deleteTable"
tooltip="Delete Table"
icon={<TableDismiss />}
icon={<TableDismiss className="w-5 h-5" />}
onClick={handleDeleteTable}
disabled={disabled}
/>
</>
) : (
<ToolbarButton
key="insertRow"
tooltip="Add Table"
icon={<TableAdd />}
icon={<TableAdd className="w-5 h-5" />}
onClick={handleTableAdd}
disabled={disabled}
/>
);
};

View File

@ -1,25 +1,67 @@
import { insertImage } from '@udecode/plate';
import { Image as ImageIcon } from '@styled-icons/material/Image';
import { ELEMENT_IMAGE, insertImage } from '@udecode/plate';
import React, { useCallback } from 'react';
import MenuItemButton from '@staticcms/core/components/common/menu/MenuItemButton';
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import MediaToolbarButton from './MediaToolbarButton';
import ToolbarButton from './ToolbarButton';
import type { Collection, MarkdownField, MediaPath } from '@staticcms/core/interface';
import type { FC } from 'react';
import type { MediaToolbarButtonProps } from './MediaToolbarButton';
const ImageToolbarButton: FC<Omit<MediaToolbarButtonProps, 'onChange'>> = props => {
interface ImageToolbarButtonProps {
variant?: 'button' | 'menu';
currentValue?: { url: string; alt?: string };
collection: Collection<MarkdownField>;
field: MarkdownField;
disabled: boolean;
}
const ImageToolbarButton: FC<ImageToolbarButtonProps> = ({
variant = 'button',
field,
collection,
currentValue,
disabled,
}) => {
const editor = useMdPlateEditorState();
const handleInsert = useCallback(
(newUrl: string) => {
if (isNotEmpty(newUrl)) {
insertImage(editor, newUrl);
(newUrl: MediaPath<string>) => {
if (isNotEmpty(newUrl.path)) {
insertImage(editor, newUrl.path);
}
},
[editor],
);
return <MediaToolbarButton {...props} onChange={handleInsert} inserting forImage />;
const openMediaLibrary = useMediaInsert(
{
path: currentValue?.url ?? '',
alt: currentValue?.alt,
},
{ collection, field, forImage: true },
handleInsert,
);
if (variant === 'menu') {
return (
<MenuItemButton key={ELEMENT_IMAGE} onClick={openMediaLibrary} startIcon={ImageIcon}>
Image
</MenuItemButton>
);
}
return (
<ToolbarButton
key="insertImage"
tooltip="Insert Image"
icon={<ImageIcon className="w-5 h-5" />}
onClick={(_editor, event) => openMediaLibrary(event)}
disabled={disabled}
/>
);
};
export default ImageToolbarButton;

View File

@ -1,17 +1,35 @@
import { Link as LinkIcon } from '@styled-icons/material/Link';
import { ELEMENT_LINK, insertLink, someNode } from '@udecode/plate';
import React, { useCallback } from 'react';
import MenuItemButton from '@staticcms/core/components/common/menu/MenuItemButton';
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import MediaToolbarButton from './MediaToolbarButton';
import ToolbarButton from './ToolbarButton';
import type { Collection, MarkdownField, MediaPath } from '@staticcms/core/interface';
import type { FC } from 'react';
import type { MediaToolbarButtonProps } from './MediaToolbarButton';
const LinkToolbarButton: FC<Omit<MediaToolbarButtonProps, 'onChange'>> = props => {
interface LinkToolbarButtonProps {
variant?: 'button' | 'menu';
currentValue?: { url: string; alt?: string };
collection: Collection<MarkdownField>;
field: MarkdownField;
disabled: boolean;
}
const LinkToolbarButton: FC<LinkToolbarButtonProps> = ({
variant = 'button',
field,
collection,
currentValue,
disabled,
}) => {
const editor = useMdPlateEditorState();
const handleInsert = useCallback(
(newUrl: string, newText: string | undefined) => {
({ path: newUrl, alt: newText }: MediaPath<string>) => {
if (isNotEmpty(newUrl)) {
insertLink(
editor,
@ -25,7 +43,34 @@ const LinkToolbarButton: FC<Omit<MediaToolbarButtonProps, 'onChange'>> = props =
const isLink = !!editor?.selection && someNode(editor, { match: { type: ELEMENT_LINK } });
return <MediaToolbarButton {...props} active={isLink} onChange={handleInsert} inserting />;
const controlID = useUUID();
const openMediaLibrary = useMediaInsert(
{
path: currentValue?.url ?? '',
alt: currentValue?.alt,
},
{ collection, field, controlID, forImage: true },
handleInsert,
);
if (variant === 'menu') {
return (
<MenuItemButton key={ELEMENT_LINK} onClick={openMediaLibrary} startIcon={LinkIcon}>
File / Link
</MenuItemButton>
);
}
return (
<ToolbarButton
key="insertLink"
tooltip="Insert Link"
icon={<LinkIcon className="w-5 h-5" />}
onClick={(_editor, event) => openMediaLibrary(event)}
active={isLink}
disabled={disabled}
/>
);
};
export default LinkToolbarButton;

View File

@ -1,124 +0,0 @@
import { focusEditor } from '@udecode/plate-core';
import React, { useCallback, useEffect, useState } from 'react';
import MediaPopover from '../../common/MediaPopover';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import ToolbarButton from './ToolbarButton';
import type { MarkdownField } from '@staticcms/core/interface';
import type { MdEditor, MediaPopoverProps } from '@staticcms/markdown';
import type { FC, MouseEvent } from 'react';
import type { ToolbarButtonProps } from './ToolbarButton';
export interface MediaToolbarButtonProps
extends Omit<ToolbarButtonProps, 'onClick'>,
Pick<
MediaPopoverProps<MarkdownField>,
'collection' | 'field' | 'entry' | 'inserting' | 'forImage' | 'textLabel'
> {
containerRef: HTMLElement | null;
mediaOpen: boolean;
onMediaToggle: (open: boolean) => void;
onChange: (newUrl: string, newText: string | undefined) => void;
onFocus?: () => void;
onBlur?: () => void;
}
const MediaToolbarButton: FC<MediaToolbarButtonProps> = ({
containerRef,
collection,
field,
entry,
inserting,
forImage,
textLabel,
mediaOpen,
onMediaToggle,
onChange,
onFocus,
onBlur,
...props
}) => {
const editor = useMdPlateEditorState();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const [internalUrl, setInternalUrl] = useState('');
const [internalText, setInternalText] = useState('');
const handleClose = useCallback(
(newValue: string | undefined, shouldFocus: boolean) => {
setAnchorEl(null);
setInternalUrl('');
setInternalText('');
if (shouldFocus) {
focusEditor(editor, editor.selection ?? editor.prevSelection!);
}
const finalValue = newValue ?? internalUrl;
if (finalValue) {
onChange(finalValue, internalText);
}
},
[editor, onChange, internalUrl, internalText],
);
const handleOnClick = useCallback(
(_editor: MdEditor, event: MouseEvent<HTMLButtonElement>) => {
if (anchorEl) {
handleClose(undefined, true);
return;
}
setAnchorEl(event.currentTarget);
},
[anchorEl, handleClose],
);
const handleMediaChange = useCallback(
(newValue: string) => {
handleClose(newValue, true);
},
[handleClose],
);
const handlePopoverClose = useCallback(
(shouldFocus: boolean) => {
handleClose(undefined, shouldFocus);
},
[handleClose],
);
useEffect(() => {
if (anchorEl && !mediaOpen) {
handleClose(undefined, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mediaOpen]);
return (
<>
<ToolbarButton onClick={handleOnClick} disableFocusAfterClick {...props} />
<MediaPopover
containerRef={containerRef}
anchorEl={anchorEl}
collection={collection}
field={field}
entry={entry}
url={internalUrl}
text={internalText}
inserting={inserting}
forImage={forImage}
textLabel={textLabel}
onUrlChange={setInternalUrl}
onTextChange={setInternalText}
mediaOpen={mediaOpen}
onMediaToggle={onMediaToggle}
onMediaChange={handleMediaChange}
onClose={handlePopoverClose}
onFocus={onFocus}
onBlur={onBlur}
/>
</>
);
};
export default MediaToolbarButton;

View File

@ -1,13 +1,12 @@
import Button from '@mui/material/Button';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import { focusEditor } from '@udecode/plate';
import React, { useCallback } from 'react';
import Button from '@staticcms/core/components/common/button/Button';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import type { MdEditor } from '@staticcms/markdown';
import type { FC, MouseEvent, ReactNode } from 'react';
import type { CSSProperties, FC, MouseEvent, ReactNode } from 'react';
export interface ToolbarButtonProps {
label?: string;
@ -16,6 +15,7 @@ export interface ToolbarButtonProps {
activeColor?: string;
icon: ReactNode;
disableFocusAfterClick?: boolean;
disabled: boolean;
onClick: (editor: MdEditor, event: MouseEvent<HTMLButtonElement>) => void;
}
@ -26,10 +26,10 @@ const ToolbarButton: FC<ToolbarButtonProps> = ({
active = false,
activeColor,
disableFocusAfterClick = false,
disabled,
onClick,
}) => {
const editor = useMdPlateEditorState();
const theme = useTheme();
const handleOnClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
@ -50,31 +50,36 @@ const ToolbarButton: FC<ToolbarButtonProps> = ({
[disableFocusAfterClick, editor, onClick],
);
return (
<Tooltip title={tooltip} disableInteractive>
<Button
aria-label={label ?? tooltip}
size="small"
color="inherit"
data-testid={`toolbar-button-${label ?? tooltip}`.replace(' ', '-').toLowerCase()}
sx={{
padding: '2px',
minWidth: 'unset',
borderRadius: '4px',
height: '26px',
width: '26px',
color: active ? activeColor ?? theme.palette.primary.main : theme.palette.text.secondary,
const style: CSSProperties = {};
if (active && activeColor) {
style.color = activeColor;
}
'& svg': {
height: '24px',
width: '24px',
},
}}
onClick={handleOnClick}
>
{icon}
</Button>
</Tooltip>
return (
<Button
aria-label={label ?? tooltip}
variant="text"
data-testid={`toolbar-button-${label ?? tooltip}`.replace(' ', '-').toLowerCase()}
onClick={handleOnClick}
className={classNames(
`
py-0.5
px-0.5
`,
active &&
!activeColor &&
`
text-blue-500
bg-gray-100
dark:text-blue-500
dark:bg-slate-800
`,
)}
style={style}
disabled={disabled}
>
{icon}
</Button>
);
};

View File

@ -1,5 +1,4 @@
import Popover from '@mui/material/Popover';
import { styled } from '@mui/material/styles';
import React, { useCallback, useState } from 'react';
import ToolbarButton from '../ToolbarButton';
@ -7,14 +6,6 @@ import ToolbarButton from '../ToolbarButton';
import type { FC, ReactNode } from 'react';
import type { ToolbarButtonProps } from '../ToolbarButton';
const StyledPopperContent = styled('div')`
display: flex;
gap: 4px;
padding: 16px;
border-radius: 4px;
align-items: center;
`;
export interface ToolbarDropdownProps extends Omit<ToolbarButtonProps, 'onClick'> {
children: ReactNode;
onClose?: () => void;
@ -58,7 +49,7 @@ const ToolbarDropdown: FC<ToolbarDropdownProps> = ({ children, onClose, ...contr
onClose={handleClose}
disablePortal
>
<StyledPopperContent>{children}</StyledPopperContent>
<div>{children}</div>
</Popover>
</>
);

View File

@ -11,7 +11,5 @@ export * from './ListToolbarButton';
export { default as ListToolbarButton } from './ListToolbarButton';
export * from './MarkToolbarButton';
export { default as MarkToolbarButton } from './MarkToolbarButton';
export * from './MediaToolbarButton';
export { default as MediaToolbarButton } from './MediaToolbarButton';
export * from './ToolbarButton';
export { default as ToolbarButton } from './ToolbarButton';

View File

@ -1,9 +1,11 @@
import CheckIcon from '@mui/icons-material/Check';
import Avatar from '@mui/material/Avatar';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { Check as CheckIcon } from '@styled-icons/material/Check';
import React, { useCallback } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import type { FC } from 'react';
export type ColorButtonProps = {
@ -38,7 +40,9 @@ const ColorButton: FC<ColorButtonProps> = ({
}}
>
{isSelected ? (
<CheckIcon sx={{ color: isBrightColor ? '#000000' : '#ffffff' }} />
<CheckIcon
className={classNames('h-5 w-5', isBrightColor ? 'text-black' : 'text-white')}
/>
) : (
<>&nbsp;</>
)}

View File

@ -1,14 +1,8 @@
import Button from '@mui/material/Button';
import { styled } from '@mui/material/styles';
import React, { useRef } from 'react';
import type { ChangeEvent, FC } from 'react';
const StyledInput = styled('input')`
visibility: hidden;
position: absolute;
`;
export interface ColorInputProps {
value?: string;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
@ -31,7 +25,7 @@ const ColorInput: FC<ColorInputProps> = ({ value = '#000000', onChange }) => {
<Button onClick={handleClick} fullWidth>
CUSTOM
</Button>
<StyledInput ref={ref} type="color" onChange={handleOnChange} value={value} />
<input ref={ref} type="color" onChange={handleOnChange} value={value} />
</div>
);
};

View File

@ -1,5 +1,4 @@
import Button from '@mui/material/Button';
import { styled } from '@mui/material/styles';
import React, { memo } from 'react';
import Colors from './Colors';
@ -8,21 +7,6 @@ import CustomColors from './CustomColors';
import type { ColorType } from '@udecode/plate';
import type { FC } from 'react';
const StyledColorPicker = styled('div')`
display: flex;
flex-direction: column;
gap: 16px;
`;
const StyledDivider = styled('div')(
({ theme }) => `
height: 1px;
width: 100%;
background: ${theme.palette.text.secondary};
opacity: 0.1;
`,
);
export type ColorPickerProps = {
color?: string;
colors: ColorType[];
@ -42,7 +26,7 @@ const ColorPickerInternal: FC<ColorPickerProps> = ({
clearColor,
}) => {
return (
<StyledColorPicker>
<div>
<CustomColors
color={color}
colors={colors}
@ -50,12 +34,12 @@ const ColorPickerInternal: FC<ColorPickerProps> = ({
updateColor={updateColor}
updateCustomColor={updateCustomColor}
/>
<StyledDivider />
<div />
<Colors color={color} colors={colors} updateColor={updateColor} />
<Button onClick={clearColor} disabled={!color}>
Clear
</Button>
</StyledColorPicker>
</div>
);
};

View File

@ -1,4 +1,3 @@
import { styled } from '@mui/material/styles';
import React from 'react';
import ColorButton from './ColorButton';
@ -6,12 +5,6 @@ import ColorButton from './ColorButton';
import type { ColorType } from '@udecode/plate';
import type { FC } from 'react';
const StyledColors = styled('div')`
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 0.25rem;
`;
export type ColorsProps = {
color?: string;
colors: ColorType[];
@ -20,7 +13,7 @@ export type ColorsProps = {
const Colors: FC<ColorsProps> = ({ color, colors, updateColor }) => {
return (
<StyledColors>
<div>
{colors.map(({ name, value, isBrightColor }) => (
<ColorButton
key={name ?? value}
@ -31,7 +24,7 @@ const Colors: FC<ColorsProps> = ({ color, colors, updateColor }) => {
updateColor={updateColor}
/>
))}
</StyledColors>
</div>
);
};

View File

@ -1,6 +1,5 @@
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { styled } from '@mui/material/styles';
import ColorInput from './ColorInput';
import Colors from './Colors';
@ -8,12 +7,6 @@ import Colors from './Colors';
import type { ColorType } from '@udecode/plate';
import type { ChangeEvent, FC } from 'react';
const StyledCustomColors = styled('div')`
display: flex;
flex-direction: column;
gap: 8px;
`;
export type CustomColorsProps = {
color?: string;
colors: ColorType[];
@ -73,10 +66,10 @@ const CustomColors: FC<CustomColorsProps> = ({
);
return (
<StyledCustomColors>
<div>
<ColorInput value={value} onChange={handleChange} />
<Colors color={color} colors={computedColors} updateColor={updateColor} />
</StyledCustomColors>
</div>
);
};

View File

@ -1,119 +1,50 @@
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import Button from '@mui/material/Button';
import Popper from '@mui/material/Popper';
import { styled, useTheme } from '@mui/material/styles';
import TextField from '@mui/material/TextField';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import PopperUnstyled from '@mui/base/PopperUnstyled';
import { DeleteForever as DeleteForeverIcon } from '@styled-icons/material/DeleteForever';
import { OpenInNew as OpenInNewIcon } from '@styled-icons/material/OpenInNew';
import React, { useCallback, useMemo } from 'react';
import useIsMediaAsset from '@staticcms/core/lib/hooks/useIsMediaAsset';
import Button from '@staticcms/core/components/common/button/Button';
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import type { Collection, Entry, FileOrImageField, MarkdownField } from '@staticcms/core/interface';
import type { ChangeEvent, KeyboardEvent } from 'react';
const StyledPopperContent = styled('div')(
({ theme }) => `
display: flex;
gap: 4px;
background: ${theme.palette.background.paper};
box-shadow: ${theme.shadows[8]};
margin: 10px 0;
padding: 6px;
border-radius: 4px;
align-items: center;
position: relative;
`,
);
const StyledPopoverContent = styled(StyledPopperContent)`
display: flex;
align-items: center;
padding: 4px 8px;
gap: 2px;
`;
const StyledPopoverEditingContent = styled(StyledPopperContent)`
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
gap: 16px;
width: 300px;
`;
const StyledFloatingVerticalDivider = styled('div')`
width: 1px;
height: 20px;
background-color: rgba(229, 231, 235, 1);
margin: 0 4px;
`;
import type {
Collection,
FileOrImageField,
MarkdownField,
MediaPath,
} from '@staticcms/core/interface';
export interface MediaPopoverProps<T extends FileOrImageField | MarkdownField> {
containerRef: HTMLElement | null;
anchorEl: HTMLElement | null;
url: string;
text?: string;
textLabel?: string;
inserting?: boolean;
forImage?: boolean;
collection: Collection<T>;
field: T;
entry: Entry;
onUrlChange: (newValue: string) => void;
onTextChange?: (newValue: string) => void;
onClose: (shouldFocus: boolean) => void;
onMediaToggle?: (open: boolean) => void;
onMediaChange: (newValue: string) => void;
onMediaChange: (newValue: MediaPath<string>) => void;
onRemove?: () => void;
onFocus?: () => void;
onBlur?: () => void;
}
const MediaPopover = <T extends FileOrImageField | MarkdownField>({
containerRef,
anchorEl,
url,
text,
textLabel = 'Text',
inserting = false,
forImage = false,
collection,
field,
entry,
onUrlChange,
onTextChange,
onClose,
onMediaToggle,
onMediaChange,
onRemove,
onFocus,
onBlur,
}: MediaPopoverProps<T>) => {
const theme = useTheme();
const buttonRef = useRef<HTMLButtonElement | null>(null);
const urlRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLInputElement | null>(null);
const [editing, setEditing] = useState(inserting);
useWindowEvent('mediaLibraryClose', () => {
onMediaToggle?.(false);
});
const handleClose = useCallback(
(shouldFocus: boolean) => {
onClose(shouldFocus);
if (!inserting) {
setEditing(false);
}
},
[inserting, onClose],
);
const isMediaAsset = useIsMediaAsset(url, collection, field, entry);
const mediaLibraryFieldOptions = useMemo(() => {
return field.media_library ?? {};
}, [field.media_library]);
@ -123,35 +54,6 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
[mediaLibraryFieldOptions],
);
const urlDisabled = useMemo(
() => !chooseUrl && isMediaAsset && forImage,
[chooseUrl, forImage, isMediaAsset],
);
useEffect(() => {
if (anchorEl) {
if (!editing) {
return;
}
if (urlDisabled) {
setTimeout(() => {
textRef.current?.focus();
});
return;
}
setTimeout(() => {
urlRef.current?.focus();
});
return;
}
if (!inserting) {
setEditing(false);
}
}, [anchorEl, editing, inserting, urlDisabled]);
const handleFocus = useCallback(() => {
onFocus?.();
}, [onFocus]);
@ -161,7 +63,7 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
}, [onBlur]);
const handleMediaChange = useCallback(
(newValue: string) => {
(newValue: MediaPath<string>) => {
onMediaChange(newValue);
onMediaToggle?.(false);
},
@ -169,136 +71,72 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
);
const handleOpenMediaLibrary = useMediaInsert(
url,
{ collection, field, forImage },
{ path: url, alt: text },
{ collection, field, forImage, insertOptions: { chooseUrl, showAlt: true } },
handleMediaChange,
);
const handleMediaOpen = useCallback(() => {
onMediaToggle?.(true);
handleOpenMediaLibrary();
}, [handleOpenMediaLibrary, onMediaToggle]);
const handleUrlChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onUrlChange(event.target.value);
},
[onUrlChange],
);
const handleTextChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onTextChange?.(event.target.value);
},
[onTextChange],
);
const handleEditStart = useCallback(() => {
setEditing(true);
}, []);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.stopPropagation();
event.preventDefault();
handleClose(true);
if (!inserting) {
setTimeout(() => {
setEditing(false);
});
}
}
},
[inserting, handleClose],
);
const open = Boolean(anchorEl);
const id = open ? 'edit-popover' : undefined;
return (
<Popper
<PopperUnstyled
id={id}
open={open}
component="div"
placement="top"
anchorEl={anchorEl}
placeholder="bottom"
container={containerRef}
sx={{ zIndex: 100 }}
onFocus={handleFocus}
onBlur={handleBlur}
disablePortal
tabIndex={0}
className="
absolute
max-h-60
overflow-auto
rounded-md
bg-white
p-1
text-base
shadow-lg
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-40
dark:bg-slate-700
"
>
{!editing ? (
<StyledPopoverContent key="edit-content" contentEditable={false}>
<Button
ref={buttonRef}
size="small"
color="inherit"
sx={{
padding: '4px 8px',
textTransform: 'none',
color: theme.palette.text.secondary,
}}
onClick={handleEditStart}
>
{forImage ? 'Edit Image' : 'Edit Link'}
<div
key="edit-content"
contentEditable={false}
className="
flex
gap-0.5
"
>
<Button onClick={handleOpenMediaLibrary} variant="text" size="small">
{forImage ? 'Edit Image' : 'Edit Link'}
</Button>
<div
className="
w-[1px]
border
border-gray-100
dark:border-slate-600
"
/>
{!forImage ? (
<Button href={url} variant="text" size="small">
<OpenInNewIcon className="w-4 h-4" title="Open In New Tab" />
</Button>
<StyledFloatingVerticalDivider />
{!forImage ? (
<Button
size="small"
color="inherit"
sx={{ padding: '4px', minWidth: 'unset', color: theme.palette.text.secondary }}
href={url}
target="_blank"
>
<OpenInNewIcon />
</Button>
) : null}
<Button
size="small"
color="inherit"
sx={{ padding: '4px', minWidth: 'unset', color: theme.palette.text.secondary }}
onClick={onRemove}
>
<DeleteForeverIcon />
</Button>
</StyledPopoverContent>
) : (
<StyledPopoverEditingContent key="editing-content" contentEditable={false}>
<TextField
key="url-input"
inputRef={urlRef}
id="url"
label="Source"
variant="outlined"
value={url}
onKeyDown={handleKeyDown}
onChange={handleUrlChange}
fullWidth
size="small"
disabled={urlDisabled}
/>
{!inserting || !forImage ? (
<TextField
key="text-input"
inputRef={textRef}
id="text"
label={textLabel}
variant="outlined"
value={text}
onKeyDown={handleKeyDown}
onChange={handleTextChange}
fullWidth
size="small"
/>
) : null}
<Button fullWidth onClick={handleMediaOpen}>
Open Media Library
</Button>
</StyledPopoverEditingContent>
)}
</Popper>
) : null}
<Button onClick={onRemove} variant="text" size="small">
<DeleteForeverIcon className="w-4 h-4" title="Delete" />
</Button>
</div>
</PopperUnstyled>
);
};

View File

@ -1,53 +1,18 @@
import { styled } from '@mui/material/styles';
import { findNodePath, setNodes } from '@udecode/plate';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Frame from 'react-frame-component';
import Outline from '@staticcms/core/components/UI/Outline';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
import CodeBlockFrame from './CodeBlockFrame';
import type { MdCodeBlockElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps, TCodeBlockElement } from '@udecode/plate';
import type { FC, MutableRefObject, RefObject } from 'react';
const StyledCodeBlock = styled('div')`
position: relative;
margin: 12px 0;
overflow: hidden;
display: flex;
flex-direction: column;
`;
const StyledInput = styled('input')`
flex-grow: 1;
outline: none;
padding: 8px;
border: 1px solid rgba(0, 0, 0, 0.35);
border-radius: 4px 4px 0 0;
width: 100%;
height: 34px;
`;
const StyledCodeBlockContent = styled('div')`
position: relative;
display: flex;
& div {
outline: none;
}
`;
const StyledHiddenChildren = styled('div')`
height: 0;
position: absolute;
`;
const CodeBlockElement: FC<PlateRenderElementProps<MdValue, MdCodeBlockElement>> = props => {
const [langHasFocus, setLangHasFocus] = useState(false);
const [codeHasFocus, setCodeHasFocus] = useState(false);
const { attributes, nodeProps, element, editor, children } = props;
const id = useUUID();
@ -68,12 +33,6 @@ const CodeBlockElement: FC<PlateRenderElementProps<MdValue, MdCodeBlockElement>>
case `code_block_${id}_onChange`:
handleChange(event.data.value);
break;
case `code_block_${id}_onFocus`:
setCodeHasFocus(true);
break;
case `code_block_${id}_onBlur`:
setCodeHasFocus(false);
break;
}
},
[handleChange, id],
@ -122,21 +81,43 @@ const CodeBlockElement: FC<PlateRenderElementProps<MdValue, MdCodeBlockElement>>
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const theme = useAppSelector(selectTheme);
return (
<>
<StyledCodeBlock {...attributes} {...nodeProps} contentEditable={false}>
<StyledInput
<div
key={theme}
{...attributes}
{...nodeProps}
contentEditable={false}
className="
my-2
"
>
<input
id={id}
value={lang}
onFocus={() => setLangHasFocus(true)}
onBlur={() => setLangHasFocus(false)}
onChange={event => {
const value = event.target.value;
const path = findNodePath(editor, element);
path && setNodes<TCodeBlockElement>(editor, { lang: value }, { at: path });
}}
className="
w-full
rounded-t-md
border
border-gray-100
border-b-white
px-2
py-1
h-6
dark:border-slate-700
dark:border-b-slate-800
dark:bg-slate-800
outline-none
"
/>
<StyledCodeBlockContent>
<div>
<Frame
key={`code-frame-${id}`}
id={id}
@ -149,12 +130,11 @@ const CodeBlockElement: FC<PlateRenderElementProps<MdValue, MdCodeBlockElement>>
}}
initialContent={initialFrameContent}
>
<CodeBlockFrame id={id} code={code} lang={lang} />
<CodeBlockFrame id={id} code={code} lang={lang} theme={theme} />
</Frame>
</StyledCodeBlockContent>
<Outline active={langHasFocus || codeHasFocus} />
<StyledHiddenChildren>{children}</StyledHiddenChildren>
</StyledCodeBlock>
</div>
<div>{children}</div>
</div>
</>
);
};

View File

@ -14,9 +14,10 @@ export interface CodeBlockFrameProps {
id: string;
lang?: string;
code: string;
theme: 'dark' | 'light';
}
const CodeBlockFrame: FC<CodeBlockFrameProps> = ({ id, lang, code }) => {
const CodeBlockFrame: FC<CodeBlockFrameProps> = ({ id, lang, code, theme }) => {
const { window } = useFrame();
const loadedLangExtension = useMemo(() => {
@ -68,6 +69,7 @@ const CodeBlockFrame: FC<CodeBlockFrameProps> = ({ id, lang, code }) => {
onBlur={handleBlur}
onChange={handleChange}
extensions={extensions}
theme={theme}
/>
);
};

View File

@ -10,7 +10,15 @@ const Heading1: FC<PlateRenderElementProps<MdValue, MdH1Element>> = ({
nodeProps,
}) => {
return (
<h1 {...attributes} {...nodeProps}>
<h1
{...attributes}
{...nodeProps}
className="
text-[2em]
font-bold
my-[0.67em]
"
>
{children}
</h1>
);

View File

@ -10,7 +10,15 @@ const Heading2: FC<PlateRenderElementProps<MdValue, MdH2Element>> = ({
nodeProps,
}) => {
return (
<h2 {...attributes} {...nodeProps}>
<h2
{...attributes}
{...nodeProps}
className="
text-[1.5em]
font-bold
my-[0.83em]
"
>
{children}
</h2>
);

View File

@ -10,7 +10,15 @@ const Heading3: FC<PlateRenderElementProps<MdValue, MdH3Element>> = ({
nodeProps,
}) => {
return (
<h3 {...attributes} {...nodeProps}>
<h3
{...attributes}
{...nodeProps}
className="
text-[1.17em]
font-bold
my-[1em]
"
>
{children}
</h3>
);

View File

@ -10,7 +10,14 @@ const Heading4: FC<PlateRenderElementProps<MdValue, MdH4Element>> = ({
nodeProps,
}) => {
return (
<h4 {...attributes} {...nodeProps}>
<h4
{...attributes}
{...nodeProps}
className="
font-bold
my-[1.33em]
"
>
{children}
</h4>
);

View File

@ -10,7 +10,15 @@ const Heading5: FC<PlateRenderElementProps<MdValue, MdH5Element>> = ({
nodeProps,
}) => {
return (
<h5 {...attributes} {...nodeProps}>
<h5
{...attributes}
{...nodeProps}
className="
text-[0.83em]
font-bold
my-[1.67em]
"
>
{children}
</h5>
);

View File

@ -10,7 +10,15 @@ const Heading6: FC<PlateRenderElementProps<MdValue, MdH6Element>> = ({
nodeProps,
}) => {
return (
<h6 {...attributes} {...nodeProps}>
<h6
{...attributes}
{...nodeProps}
className="
text-[0.67em]
font-bold
my-[2.33em]
"
>
{children}
</h6>
);

View File

@ -14,28 +14,26 @@ import { isEmpty } from '@staticcms/core/lib/util/string.util';
import { MediaPopover } from '@staticcms/markdown';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
import type { Collection, Entry, MarkdownField, MediaPath } from '@staticcms/core/interface';
import type { MdImageElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { TMediaElement } from '@udecode/plate-media';
import type { FC } from 'react';
export interface WithImageElementProps {
containerRef: HTMLElement | null;
collection: Collection<MarkdownField>;
entry: Entry;
field: MarkdownField;
}
const withImageElement = ({ containerRef, collection, entry, field }: WithImageElementProps) => {
const withImageElement = ({ collection, entry, field }: WithImageElementProps) => {
const ImageElement: FC<PlateRenderElementProps<MdValue, MdImageElement>> = ({
element,
editor,
children,
}) => {
const { url, alt } = element;
const [internalUrl, setInternalUrl] = useState(url);
const [internalAlt, setInternalAlt] = useState(alt);
const [internalValue, setInternalValue] = useState<MediaPath<string>>({ path: url, alt });
const [popoverHasFocus, setPopoverHasFocus] = useState(false);
const debouncedPopoverHasFocus = useDebounce(popoverHasFocus, 100);
@ -94,16 +92,15 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE
const handleClose = useCallback(() => {
setAnchorEl(null);
handleChange(internalUrl, 'url');
handleChange(internalAlt ?? '', 'alt');
}, [handleChange, internalAlt, internalUrl]);
}, []);
const assetSource = useMediaAsset(url, collection, field, entry);
const handleMediaChange = useCallback(
(newValue: string) => {
handleChange(newValue, 'url');
setInternalUrl(newValue);
(newValue: MediaPath<string>) => {
handleChange(newValue.path, 'url');
handleChange(newValue.alt ?? '', 'alt');
setInternalValue(newValue);
},
[handleChange],
);
@ -180,16 +177,10 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE
/>
<MediaPopover
anchorEl={anchorEl}
containerRef={containerRef}
collection={collection}
field={field}
entry={entry}
url={internalUrl}
text={internalAlt ?? ''}
textLabel="Alt"
onUrlChange={setInternalUrl}
onTextChange={setInternalAlt}
onClose={handleClose}
url={internalValue.path}
text={internalValue.alt}
onMediaChange={handleMediaChange}
onRemove={handleRemove}
forImage

View File

@ -14,19 +14,17 @@ import { useFocused } from 'slate-react';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
import MediaPopover from '../../common/MediaPopover';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
import type { Collection, MarkdownField, MediaPath } from '@staticcms/core/interface';
import type { MdLinkElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps, TText } from '@udecode/plate';
import type { FC, MouseEvent } from 'react';
export interface WithLinkElementProps {
containerRef: HTMLElement | null;
collection: Collection<MarkdownField>;
field: MarkdownField;
entry: Entry;
}
const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkElementProps) => {
const withLinkElement = ({ collection, field }: WithLinkElementProps) => {
const LinkElement: FC<PlateRenderElementProps<MdValue, MdLinkElement>> = ({
attributes: { ref: _ref, ...attributes },
children,
@ -39,8 +37,10 @@ const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkEle
const { url } = element;
const path = findNodePath(editor, element);
const [internalUrl, setInternalUrl] = useState(url);
const [internalText, setInternalText] = useState(getEditorString(editor, path));
const [internalValue, setInternalValue] = useState<MediaPath<string>>({
path: url,
alt: getEditorString(editor, path),
});
const [popoverHasFocus, setPopoverHasFocus] = useState(false);
const debouncedPopoverHasFocus = useDebounce(popoverHasFocus, 100);
@ -112,17 +112,16 @@ const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkEle
);
const handleMediaChange = useCallback(
(newValue: string) => {
handleChange(newValue, internalText);
setInternalUrl(newValue);
(newValue: MediaPath<string>) => {
handleChange(newValue.path, newValue.alt ?? '');
setInternalValue(newValue);
},
[handleChange, internalText],
[handleChange],
);
const handleClose = useCallback(() => {
setAnchorEl(null);
handleChange(internalUrl, internalText);
}, [handleChange, internalText, internalUrl]);
}, []);
useEffect(() => {
if (
@ -200,20 +199,26 @@ const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkEle
return (
<span onBlur={handleBlur}>
<a ref={urlRef} {...attributes} href={url} {...nodeProps} onClick={handleClick}>
<a
ref={urlRef}
{...attributes}
href={url}
{...nodeProps}
onClick={handleClick}
className="
text-blue-500
cursor-pointer
hover:underline
"
>
{children}
</a>
<MediaPopover
anchorEl={anchorEl}
containerRef={containerRef}
collection={collection}
field={field}
entry={entry}
url={internalUrl}
text={internalText}
onUrlChange={setInternalUrl}
onTextChange={setInternalText}
onClose={handleClose}
url={internalValue.path}
text={internalValue.alt}
onMediaChange={handleMediaChange}
onRemove={handleRemove}
onFocus={handlePopoverFocus}

View File

@ -26,7 +26,7 @@ const ListItemElement: FC<PlateRenderElementProps<MdValue, MdListItemElement>> =
return (
<li>
{isNotNullish(checked) ? (
<input type="checkbox" checked={checked} onChange={handleChange} />
<input type="checkbox" checked={checked} onChange={handleChange} className="m-[5px] mr-2" />
) : null}
{children}
</li>

View File

@ -7,7 +7,16 @@ import type { FC } from 'react';
const OrderedListElement: FC<PlateRenderElementProps<MdValue, MdNumberedListElement>> = ({
children,
}) => {
return <ol>{children}</ol>;
return (
<ol
className="
list-decimal
pl-10
"
>
{children}
</ol>
);
};
export default OrderedListElement;

View File

@ -7,7 +7,16 @@ import type { FC } from 'react';
const UnorderedListElement: FC<PlateRenderElementProps<MdValue, MdBulletedListElement>> = ({
children,
}) => {
return <ul>{children}</ul>;
return (
<ul
className="
list-disc
pl-10
"
>
{children}
</ul>
);
};
export default UnorderedListElement;

View File

@ -8,7 +8,17 @@ const ParagraphElement: FC<PlateRenderElementProps<MdValue, MdParagraphElement>>
children,
element: { align },
}) => {
return <p style={{ textAlign: align }}>{children}</p>;
return (
<p
style={{ textAlign: align }}
className="
block
my-[1em]
"
>
{children}
</p>
);
};
export default ParagraphElement;

View File

@ -1,7 +1,9 @@
import { findNodePath, setNodes } from '@udecode/plate';
import React, { useCallback, useMemo } from 'react';
import { getShortcode } from '../../../../../../lib/registry';
import { getShortcode } from '@staticcms/core/lib/registry';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
import type { MarkdownField, WidgetControlProps } from '@staticcms/core/interface';
import type { MdShortcodeElement, MdValue } from '@staticcms/markdown';
@ -41,10 +43,17 @@ const withShortcodeElement = ({ controlProps }: WithShortcodeElementProps) => {
[config, editor, element],
);
const theme = useAppSelector(selectTheme);
return (
<span contentEditable={false}>
{ShortcodeControl ? (
<ShortcodeControl controlProps={controlProps} onChange={handleOnChange} {...props} />
<ShortcodeControl
controlProps={controlProps}
onChange={handleOnChange}
theme={theme}
{...props}
/>
) : null}
{children}
</span>

View File

@ -1,4 +1,3 @@
import Box from '@mui/material/Box';
import React from 'react';
import type { MdTableCellElement, MdValue } from '@staticcms/markdown';
@ -11,19 +10,22 @@ const TableHeaderCellElement: FC<PlateRenderElementProps<MdValue, MdTableCellEle
nodeProps,
}) => {
return (
<Box
component="td"
<td
{...attributes}
{...nodeProps}
sx={{
padding: '8px',
'&:not(:last-of-type)': {
borderRight: '1px solid rgba(209,213,219,0.5)',
},
}}
className="
px-2
py-1
[&>div>p]:m-0
border-r
border-gray-200
last:border-0
dark:border-gray-800
text-sm
"
>
<div>{children}</div>
</Box>
</td>
);
};

View File

@ -1,4 +1,3 @@
import Box from '@mui/material/Box';
import React from 'react';
import type { MdTableCellElement, MdValue } from '@staticcms/markdown';
@ -11,21 +10,25 @@ const TableHeaderCellElement: FC<PlateRenderElementProps<MdValue, MdTableCellEle
nodeProps,
}) => {
return (
<Box
component="th"
<th
{...attributes}
{...nodeProps}
sx={{
padding: '8px',
background: 'rgb(244,245,247)',
textAlign: 'left',
'&:not(:last-of-type)': {
borderRight: '1px solid rgba(209,213,219,0.5)',
},
}}
className="
px-2
py-1
[&>div>p]:m-0
text-left
bg-slate-300
text-sm
border-r
border-gray-200
last:border-0
dark:bg-slate-700
dark:border-gray-800
"
>
<div>{children}</div>
</Box>
</th>
);
};

View File

@ -1,4 +1,3 @@
import Box from '@mui/system/Box';
import { useSelectedCells } from '@udecode/plate';
import React from 'react';
@ -14,11 +13,17 @@ const TableElement: FC<PlateRenderElementProps<MdValue, MdTableElement>> = ({
useSelectedCells();
return (
<Box
component="table"
<table
{...attributes}
{...nodeProps}
sx={{ border: '1px solid rgba(209,213,219,0.75)', borderCollapse: 'collapse' }}
className="
border-collapse
border
border-gray-200
dark:border-slate-700
rounded-md
my-4
"
>
{children ? (
<>
@ -26,7 +31,7 @@ const TableElement: FC<PlateRenderElementProps<MdValue, MdTableElement>> = ({
<tbody key="tbody">{children.slice(1)}</tbody>
</>
) : null}
</Box>
</table>
);
};

View File

@ -1,5 +1,4 @@
import React from 'react';
import Box from '@mui/system/Box';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
@ -11,18 +10,18 @@ const TableRowElement: FC<PlateRenderElementProps<MdValue, MdTableRowElement>> =
nodeProps,
}) => {
return (
<Box
component="tr"
<tr
{...attributes}
{...nodeProps}
sx={{
'&:only-of-type, &:not(:last-of-type)': {
borderBottom: '1px solid rgba(209,213,219,0.5)',
},
}}
className="
border-b
border-gray-200
last:border-0
dark:border-gray-800
"
>
{children}
</Box>
</tr>
);
};

View File

@ -1,79 +1,62 @@
import { styled } from '@mui/material/styles';
import React from 'react';
import AddButtons from '../buttons/AddButtons';
import AlignToolbarButtons from '../buttons/AlignToolbarButtons';
import BasicElementToolbarButtons from '../buttons/BasicElementToolbarButtons';
import BasicMarkToolbarButtons from '../buttons/BasicMarkToolbarButtons';
import ColorToolbarButtons from '../buttons/ColorToolbarButtons';
import ListToolbarButtons from '../buttons/ListToolbarButtons';
import MediaToolbarButton from '../buttons/MediaToolbarButtons';
import ShortcodeToolbarButton from '../buttons/ShortcodeToolbarButton';
import type { Collection, MarkdownField } from '@staticcms/core/interface';
import type { FC } from 'react';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
const StyledToolbar = styled('div')(
({ theme }) => `
display: flex;
align-items: center;
user-select: none;
box-sizing: content-box;
color: rgb(68,68,68);
min-height: 40px;
position: relative;
flex-wrap: wrap;
margin-top: -1.25rem;
margin-left: -1.25rem;
margin-right: -1.25rem;
padding: 12px;
border-bottom: 2px solid #eee;
gap:2px;
background: ${theme.palette.background.paper};
`,
);
const StyledDivider = styled('div')(
({ theme }) => `
height: 18px;
width: 1px;
background: ${theme.palette.text.secondary};
margin: 0 4px;
opacity: 0.5;
`,
);
export interface ToolbarProps {
useMdx: boolean;
containerRef: HTMLElement | null;
collection: Collection<MarkdownField>;
field: MarkdownField;
entry: Entry;
disabled: boolean;
}
const Toolbar: FC<ToolbarProps> = ({ useMdx, containerRef, collection, field, entry }) => {
const Toolbar: FC<ToolbarProps> = ({ useMdx, collection, field, disabled }) => {
const groups = [
<BasicMarkToolbarButtons key="basic-mark-buttons" useMdx={useMdx} extended />,
<BasicElementToolbarButtons key="basic-element-buttons" />,
<ListToolbarButtons key="list-buttons" />,
useMdx ? <ColorToolbarButtons key="color-buttons" /> : null,
useMdx ? <AlignToolbarButtons key="align-mark-buttons" /> : null,
<MediaToolbarButton
key="media-buttons"
containerRef={containerRef}
collection={collection}
field={field}
entry={entry}
<BasicMarkToolbarButtons
key="basic-mark-buttons"
useMdx={useMdx}
extended
disabled={disabled}
/>,
!useMdx ? <ShortcodeToolbarButton key="shortcode-button" /> : null,
<BasicElementToolbarButtons key="basic-element-buttons" disabled={disabled} />,
<ListToolbarButtons key="list-buttons" disabled={disabled} />,
useMdx ? <ColorToolbarButtons key="color-buttons" disabled={disabled} /> : null,
useMdx ? <AlignToolbarButtons key="align-mark-buttons" disabled={disabled} /> : null,
!useMdx ? <ShortcodeToolbarButton key="shortcode-button" disabled={disabled} /> : null,
<AddButtons key="add-buttons" collection={collection} field={field} disabled={disabled} />,
].filter(Boolean);
return (
<StyledToolbar>
{groups.map((group, index) => [
index !== 0 ? <StyledDivider key={`toolbar-divider-${index}`} /> : null,
group,
])}
</StyledToolbar>
<div
className="
flex
flex-wrap
relative
items-center
select-none
min-h-markdown-toolbar
-mx-3
-my-5
px-2
pt-2
pb-1.5
mb-1.5
border-bottom-2
border-gray-400
gap-0.5
shadow-md
"
>
{groups}
</div>
);
};

View File

@ -1,3 +1,4 @@
/* eslint-disable no-useless-escape */
import {
deserializationOnlyTestData,
runSerializationTests,
@ -22,30 +23,9 @@ async function expectNodes(
expect(await markdownToSlate(markdown, options)).toEqual(children);
}
function sanitizeHtmlInMarkdown(markdown: string) {
return markdown
.replace('</font>', '<\\/font>')
.replace('<u>', '<u\\>')
.replace('</u>', '<\\/u>')
.replace('<sub>', '<sub\\>')
.replace('</sub>', '<\\/sub>')
.replace('<sup>', '<sup\\>')
.replace('</sup>', '<\\/sup>');
}
function testRunner(key: string, mode: 'markdown' | 'mdx' | 'both', data: SerializationTestData) {
it(`deserializes ${key}`, async () => {
if (mode === 'both') {
await expectNodes(data.markdown, { shortcodeConfigs, useMdx: false }, data.slate);
await expectNodes(data.markdown, { shortcodeConfigs, useMdx: true }, data.slate);
return;
}
await expectNodes(
mode === 'markdown' ? sanitizeHtmlInMarkdown(data.markdown) : data.markdown,
{ shortcodeConfigs, useMdx: mode === 'mdx' },
data.slate,
);
await expectNodes(data.markdown, { shortcodeConfigs, useMdx: mode === 'mdx' }, data.slate);
});
}

View File

@ -13,34 +13,40 @@ export interface UseMdxState {
file: VFile | null;
}
export default function useMdx(input: string): [UseMdxState, (value: string) => void] {
export default function useMdx(
name: string,
input: string,
): [UseMdxState, (value: string) => void] {
const [state, setState] = useState<UseMdxState>({ file: null });
const setValueCallback = useCallback(async (value: string) => {
const file = new VFile({ basename: 'editor.mdx', value });
const setValueCallback = useCallback(
async (value: string) => {
const file = new VFile({ basename: name, value });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options: any = {
...provider,
...runtime,
useDynamicImport: true,
remarkPlugins: [remarkGfm, flattenListItemParagraphs],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options: any = {
...provider,
...runtime,
useDynamicImport: true,
remarkPlugins: [remarkGfm, flattenListItemParagraphs],
};
try {
file.result = (await evaluate(file, options)).default;
} catch (error) {
const message = error instanceof VFileMessage ? error : new VFileMessage(String(error));
try {
file.result = (await evaluate(file, options)).default;
} catch (error) {
const message = error instanceof VFileMessage ? error : new VFileMessage(String(error));
if (!file.messages.includes(message)) {
file.messages.push(message);
if (!file.messages.includes(message)) {
file.messages.push(message);
}
message.fatal = true;
}
message.fatal = true;
}
setState({ file });
}, []);
setState({ file });
},
[name],
);
const setValue = useDebouncedCallback(setValueCallback, 100);

View File

@ -14,12 +14,6 @@ function expectMarkdown(nodes: MdValue, options: { useMdx: boolean }, markdown:
describe('serializeMarkdown', () => {
runSerializationTests((key, mode, data) => {
it(`serializes ${key}`, async () => {
if (mode === 'both') {
await expectMarkdown(data.slate, { useMdx: false }, data.markdown);
await expectMarkdown(data.slate, { useMdx: true }, data.markdown);
return;
}
await expectMarkdown(data.slate, { useMdx: mode === 'mdx' }, data.markdown);
});
});

View File

@ -224,17 +224,17 @@ function serializeMarkdownNode(
switch (type) {
case NodeTypes.heading[1]:
return `# ${children}\n`;
return `# ${handleInBlockNewline(children)}\n`;
case NodeTypes.heading[2]:
return `## ${children}\n`;
return `## ${handleInBlockNewline(children)}\n`;
case NodeTypes.heading[3]:
return `### ${children}\n`;
return `### ${handleInBlockNewline(children)}\n`;
case NodeTypes.heading[4]:
return `#### ${children}\n`;
return `#### ${handleInBlockNewline(children)}\n`;
case NodeTypes.heading[5]:
return `##### ${children}\n`;
return `##### ${handleInBlockNewline(children)}\n`;
case NodeTypes.heading[6]:
return `###### ${children}\n`;
return `###### ${handleInBlockNewline(children)}\n`;
case NodeTypes.block_quote:
return `${selfIsBlockquote && blockquoteDepth > 0 ? '\n' : ''}> ${children
@ -379,6 +379,10 @@ function getTableColumnCount(tableNode: TableNode): number {
return rows[0].children.length;
}
function handleInBlockNewline(children: string) {
return children.replace(/\n/g, '\\\n');
}
export interface SerializeMarkdownOptions {
useMdx: boolean;
shortcodeConfigs?: Record<string, ShortcodeConfig<{}>>;

View File

@ -21,12 +21,6 @@ async function expectNodes(
function testRunner(key: string, mode: 'markdown' | 'mdx' | 'both', data: SerializationTestData) {
it(`deserializes ${key}`, async () => {
if (mode === 'both') {
await expectNodes(data.mdast, false, data.slate);
await expectNodes(data.mdast, true, data.slate);
return;
}
await expectNodes(data.mdast, mode === 'mdx', data.slate);
});
}

View File

@ -40,7 +40,9 @@ function persistLeafFormats(
): Omit<MdastNode, 'children' | 'type' | 'text'> {
return children.reduce((acc, node) => {
(Object.keys(node) as Array<keyof MdastNode>).forEach(function (key) {
if (key === 'children' || key === 'type' || key === 'text') return;
if (key === 'children' || key === 'type' || key === 'text') {
return;
}
acc[key] = node[key];
});

View File

@ -458,6 +458,48 @@ And a completely new paragraph`,
},
],
},
'Multiline Header': {
markdown: `# Line One\
Line Two`,
mdast: {
type: 'root',
children: [
{
type: 'heading',
depth: 1,
children: [
{
type: 'text',
value: 'Line One Line Two',
position: {
start: { line: 1, column: 3, offset: 2 },
end: { line: 1, column: 13, offset: 12 },
},
},
],
position: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 13, offset: 12 },
},
},
],
position: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 13, offset: 12 },
},
},
slate: [
{
type: ELEMENT_H1,
children: [
{
text: 'Line One Line Two',
},
],
},
],
},
},
},
@ -2566,48 +2608,6 @@ And a completely new paragraph`,
},
subscript: {
markdown: {
'subscript tag': {
markdown: '<sub>Subscript</sub>',
mdast: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value: '<sub>Subscript</sub>',
position: {
start: { line: 1, column: 6, offset: 5 },
end: { line: 1, column: 15, offset: 14 },
},
},
],
position: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 21, offset: 20 },
},
},
],
position: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 21, offset: 20 },
},
},
slate: [
{
type: ELEMENT_PARAGRAPH,
children: [
{
text: '<sub>Subscript</sub>',
},
],
},
],
},
},
mdx: {
'subscript tag': {
markdown: '<sub>Subscript</sub>',
@ -2664,48 +2664,6 @@ And a completely new paragraph`,
},
superscript: {
markdown: {
'superscript tag': {
markdown: '<sup>Superscript</sup>',
mdast: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value: '<sup>Superscript</sup>',
position: {
start: { line: 1, column: 6, offset: 5 },
end: { line: 1, column: 17, offset: 16 },
},
},
],
position: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 23, offset: 22 },
},
},
],
position: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 23, offset: 22 },
},
},
slate: [
{
type: ELEMENT_PARAGRAPH,
children: [
{
text: '<sup>Superscript</sup>',
},
],
},
],
},
},
mdx: {
'superscript tag': {
markdown: '<sup>Superscript</sup>',
@ -2762,48 +2720,6 @@ And a completely new paragraph`,
},
underline: {
markdown: {
'underline tag': {
markdown: '<u>Underlined</u>',
mdast: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value: '<u>Underlined</u>',
position: {
start: { line: 1, column: 4, offset: 3 },
end: { line: 1, column: 14, offset: 13 },
},
},
],
position: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 18, offset: 17 },
},
},
],
position: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 18, offset: 17 },
},
},
slate: [
{
type: ELEMENT_PARAGRAPH,
children: [
{
text: '<u>Underlined</u>',
},
],
},
],
},
},
mdx: {
'underline tag': {
markdown: '<u>Underlined</u>',
@ -2860,49 +2776,6 @@ And a completely new paragraph`,
},
'font tags': {
markdown: {
'font tag': {
markdown: "<font style={{ color: 'red', backgroundColor: 'black' }}>Colored Text</font>",
mdast: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value:
"<font style={{ color: 'red', backgroundColor: 'black' }}>Colored Text</font>",
position: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 70, offset: 69 },
},
},
],
position: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 77, offset: 76 },
},
},
],
position: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 77, offset: 76 },
},
},
slate: [
{
type: ELEMENT_PARAGRAPH,
children: [
{
text: "<font style={{ color: 'red', backgroundColor: 'black' }}>Colored Text</font>",
},
],
},
],
},
},
mdx: {
'color and background color from style attribute of font tag': {
markdown: "<font style={{ color: 'red', backgroundColor: 'black' }}>Colored Text</font>",
@ -7617,9 +7490,11 @@ export function runSerializationTests(
describe(key, () => {
if (data.markdown) {
runSectionSerializationTests('markdown', 'markdown', data.markdown, testCallback);
}
if (data.mdx) {
} else if (data.mdx) {
runSectionSerializationTests('mdx', 'mdx', data.mdx, testCallback);
} else if (data.both) {
runSectionSerializationTests('markdown', 'markdown', data.both, testCallback);
runSectionSerializationTests('mdx', 'mdx', data.both, testCallback);
}
});
});

View File

@ -1,8 +1,6 @@
import { styled } from '@mui/material/styles';
import React, { useCallback, useMemo, useState } from 'react';
import FieldLabel from '@staticcms/core/components/UI/FieldLabel';
import Outline from '@staticcms/core/components/UI/Outline';
import Field from '@staticcms/core/components/common/field/Field';
import useDebounce from '../../lib/hooks/useDebounce';
import useMarkdownToSlate from './plate/hooks/useMarkdownToSlate';
import PlateEditor from './plate/PlateEditor';
@ -12,40 +10,30 @@ import type { MarkdownField, WidgetControlProps } from '@staticcms/core/interfac
import type { FC } from 'react';
import type { MdValue } from './plate/plateTypes';
const StyledEditorWrapper = styled('div')`
position: relative;
width: 100%;
.toastui-editor-main .toastui-editor-md-vertical-style .toastui-editor {
width: 100%;
}
.toastui-editor-main .toastui-editor-md-splitter {
display: none;
}
.toastui-editor-md-preview {
display: none;
}
.toastui-editor-defaultUI {
border: none;
}
`;
export interface WithMarkdownControlProps {
useMdx: boolean;
}
const withMarkdownControl = ({ useMdx }: WithMarkdownControlProps) => {
const MarkdownControl: FC<WidgetControlProps<string, MarkdownField>> = controlProps => {
const { label, value, isDuplicate, onChange, hasErrors, collection, entry, field } =
controlProps;
const {
label,
value,
duplicate,
onChange,
hasErrors,
collection,
entry,
field,
errors,
forSingleList,
disabled,
} = controlProps;
const [internalRawValue, setInternalValue] = useState(value ?? '');
const internalValue = useMemo(
() => (isDuplicate ? value ?? '' : internalRawValue),
[internalRawValue, isDuplicate, value],
() => (duplicate ? value ?? '' : internalRawValue),
[internalRawValue, duplicate, value],
);
const [hasFocus, setHasFocus] = useState(false);
const debouncedFocus = useDebounce(hasFocus, 150);
@ -77,15 +65,14 @@ const withMarkdownControl = ({ useMdx }: WithMarkdownControlProps) => {
return useMemo(
() => (
<StyledEditorWrapper key="markdown-control-wrapper">
<FieldLabel
key="markdown-control-label"
isActive={hasFocus}
hasErrors={hasErrors}
onClick={handleLabelClick}
>
{label}
</FieldLabel>
<Field
label={label}
errors={errors}
forSingleList={forSingleList}
hint={field.hint}
noHightlight
disabled={disabled}
>
{loaded ? (
<PlateEditor
initialValue={slateValue}
@ -99,13 +86,7 @@ const withMarkdownControl = ({ useMdx }: WithMarkdownControlProps) => {
onBlur={handleOnBlur}
/>
) : null}
<Outline
key="markdown-control-outline"
hasLabel
hasError={hasErrors}
active={hasFocus || debouncedFocus}
/>
</StyledEditorWrapper>
</Field>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[

View File

@ -1,22 +0,0 @@
import withMarkdownControl from '../markdown/withMarkdownControl';
import previewComponent from '../markdown/MarkdownPreview';
import schema from '../markdown/schema';
import type { MarkdownField, WidgetParam } from '@staticcms/core/interface';
const controlComponent = withMarkdownControl({ useMdx: true });
const MdxWidget = (): WidgetParam<string, MarkdownField> => {
return {
name: 'mdx',
controlComponent,
previewComponent,
options: {
schema,
},
};
};
export { controlComponent as MdxControl, previewComponent as MdxPreview, schema as MdxSchema };
export default MdxWidget;

View File

@ -1,84 +1,39 @@
import TextField from '@mui/material/TextField';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { FieldError, NumberField, WidgetControlProps } from '@staticcms/core/interface';
import Field from '@staticcms/core/components/common/field/Field';
import TextField from '@staticcms/core/components/common/text-field/TextField';
import type { NumberField, WidgetControlProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC } from 'react';
import type { t } from 'react-polyglot';
const ValidationErrorTypes = {
PRESENCE: 'PRESENCE',
PATTERN: 'PATTERN',
RANGE: 'RANGE',
CUSTOM: 'CUSTOM',
};
export function validateNumberMinMax(
value: string | number,
min: number | false,
max: number | false,
field: NumberField,
t: t,
): FieldError | false {
let error: FieldError | false;
switch (true) {
case value !== '' && min !== false && max !== false && (value < min || value > max):
error = {
type: ValidationErrorTypes.RANGE,
message: t('editor.editorControlPane.widget.range', {
fieldLabel: field.label ?? field.name,
minValue: min,
maxValue: max,
}),
};
break;
case value !== '' && min !== false && value < min:
error = {
type: ValidationErrorTypes.RANGE,
message: t('editor.editorControlPane.widget.min', {
fieldLabel: field.label ?? field.name,
minValue: min,
}),
};
break;
case value !== '' && max !== false && value > max:
error = {
type: ValidationErrorTypes.RANGE,
message: t('editor.editorControlPane.widget.max', {
fieldLabel: field.label ?? field.name,
maxValue: max,
}),
};
break;
default:
error = false;
break;
}
return error;
}
const NumberControl: FC<WidgetControlProps<string | number, NumberField>> = ({
label,
field,
value,
isDuplicate,
label,
errors,
disabled,
forSingleList,
duplicate,
onChange,
hasErrors,
}) => {
const [internalRawValue, setInternalValue] = useState(value ?? '');
const internalValue = useMemo(
() => (isDuplicate ? value ?? '' : internalRawValue),
[internalRawValue, isDuplicate, value],
() => (duplicate ? value ?? '' : internalRawValue),
[internalRawValue, duplicate, value],
);
const ref = useRef<HTMLInputElement | null>(null);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
(e: ChangeEvent<HTMLInputElement>) => {
const valueType = field.value_type;
let newValue: string | number =
valueType === 'float' ? parseFloat(e.target.value) : parseInt(e.target.value, 10);
let newValue: string | number = e.target.value;
if (valueType === 'float') {
newValue = parseFloat(e.target.value);
} else if (valueType === 'int') {
newValue = parseInt(e.target.value, 10);
}
if (isNaN(newValue)) {
if (typeof newValue !== 'string' && isNaN(newValue)) {
newValue = '';
}
onChange(newValue);
@ -87,28 +42,41 @@ const NumberControl: FC<WidgetControlProps<string | number, NumberField>> = ({
[field, onChange],
);
const min = field.min ?? '';
const max = field.max ?? '';
const step = field.step ?? (field.value_type === 'int' ? 1 : '');
const min = useMemo(() => field.min ?? '', [field.min]);
const max = useMemo(() => field.max ?? '', [field.max]);
const step = useMemo(() => {
if (field.step) {
if (field.value_type === 'int') {
return Math.round(field.step);
}
return field.step;
}
return 1;
}, [field.step, field.value_type]);
return (
<TextField
key="number-control-input"
variant="outlined"
type="number"
value={internalValue}
onChange={handleChange}
inputProps={{
step,
min,
max,
}}
fullWidth
<Field
inputRef={ref}
label={label}
error={hasErrors}
InputLabelProps={{
shrink: true,
}}
/>
errors={errors}
hint={field.hint}
forSingleList={forSingleList}
cursor="text"
disabled={disabled}
>
<TextField
type="number"
inputRef={ref}
value={internalValue}
min={min}
max={max}
step={step}
disabled={disabled}
onChange={handleChange}
/>
</Field>
);
};

View File

@ -1,12 +1,10 @@
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import type { NumberField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const NumberPreview: FC<WidgetPreviewProps<string | number, NumberField>> = ({ value }) => {
return <WidgetPreviewContainer>{value}</WidgetPreviewContainer>;
return <div>{value}</div>;
};
export default NumberPreview;

View File

@ -0,0 +1,261 @@
/**
* @jest-environment jsdom
*/
import '@testing-library/jest-dom';
import { act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { mockNumberField } from '@staticcms/test/data/fields.mock';
import { createWidgetControlHarness } from '@staticcms/test/harnesses/widget.harness';
import NumberControl from '../NumberControl';
import type { NumberField } from '@staticcms/core/interface';
describe(NumberControl.name, () => {
const renderControl = createWidgetControlHarness(NumberControl, { field: mockNumberField });
it('should render', () => {
const { getByTestId } = renderControl({ label: 'I am a label' });
expect(getByTestId('number-input')).toBeInTheDocument();
const label = getByTestId('label');
expect(label.textContent).toBe('I am a label');
expect(label).toHaveClass('text-slate-500');
const field = getByTestId('field');
expect(field).toHaveClass('group/active');
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).not.toHaveClass('mr-14');
// Number Widget uses text cursor
expect(label).toHaveClass('cursor-text');
expect(field).toHaveClass('cursor-text');
// Number Widget uses default label layout, with bottom padding on field
expect(label).toHaveClass('px-3', 'pt-3');
expect(field).toHaveClass('pb-3');
});
it('should render as single list item', () => {
const { getByTestId } = renderControl({ label: 'I am a label', forSingleList: true });
expect(getByTestId('number-input')).toBeInTheDocument();
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).toHaveClass('mr-14');
});
it('should only use prop value as initial value', async () => {
const { rerender, getByTestId } = renderControl({ value: '5' });
const inputWrapper = getByTestId('number-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue(5);
rerender({ value: '76' });
expect(input).toHaveValue(5);
});
it('should use prop value exclusively if field is i18n duplicate', async () => {
const { rerender, getByTestId } = renderControl({
field: { ...mockNumberField, i18n: 'duplicate' },
duplicate: true,
value: '5',
});
const inputWrapper = getByTestId('number-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue(5);
rerender({ value: '76' });
expect(input).toHaveValue(76);
});
it('should call onChange when text input changes', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl();
const inputWrapper = getByTestId('number-input');
const input = inputWrapper.getElementsByTagName('input')[0];
await act(async () => {
await userEvent.type(input, '1056');
});
expect(onChange).toHaveBeenLastCalledWith('1056');
});
it('should show error', async () => {
const { getByTestId } = renderControl({
errors: [{ type: 'error-type', message: 'i am an error' }],
});
const error = getByTestId('error');
expect(error.textContent).toBe('i am an error');
const field = getByTestId('field');
expect(field).not.toHaveClass('group/active');
const label = getByTestId('label');
expect(label).toHaveClass('text-red-500');
});
it('should focus input on field click', async () => {
const { getByTestId } = renderControl();
const inputWrapper = getByTestId('number-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).not.toHaveFocus();
const field = getByTestId('field');
await act(async () => {
await userEvent.click(field);
});
expect(input).toHaveFocus();
});
it('should disable input if disabled', async () => {
const { getByTestId } = await renderControl({ disabled: true });
const inputWrapper = getByTestId('number-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toBeDisabled();
});
it('should pass min, max and step to input', async () => {
const { getByTestId } = renderControl({
field: {
...mockNumberField,
min: 10,
max: 250,
step: 5,
},
});
const inputWrapper = getByTestId('number-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveAttribute('min', '10');
expect(input).toHaveAttribute('max', '250');
expect(input).toHaveAttribute('step', '5');
});
it('should default to step of 1 if step is not set', async () => {
const { getByTestId } = renderControl();
const inputWrapper = getByTestId('number-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveAttribute('step', '1');
});
describe('int', () => {
const mockIntNumberField: NumberField = {
label: 'Number',
name: 'mock_number',
widget: 'number',
value_type: 'int',
};
it('should call onChange when text input changes', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({ field: mockIntNumberField });
const inputWrapper = getByTestId('number-input');
const input = inputWrapper.getElementsByTagName('input')[0];
await act(async () => {
await userEvent.type(input, '1056.5');
});
expect(onChange).toHaveBeenLastCalledWith(1056);
});
it('should round step to nearest whole number', async () => {
const { getByTestId } = renderControl({
field: {
...mockIntNumberField,
step: 5.25,
},
});
const inputWrapper = getByTestId('number-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveAttribute('step', '5');
});
it('should default to step of 1 if step is not set', async () => {
const { getByTestId } = renderControl({
field: {
...mockIntNumberField,
},
});
const inputWrapper = getByTestId('number-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveAttribute('step', '1');
});
});
describe('float', () => {
const mockIntNumberField: NumberField = {
label: 'Number',
name: 'mock_number',
widget: 'number',
value_type: 'float',
};
it('should call onChange when text input changes', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({ field: mockIntNumberField });
const inputWrapper = getByTestId('number-input');
const input = inputWrapper.getElementsByTagName('input')[0];
await act(async () => {
await userEvent.type(input, '1056.5');
});
expect(onChange).toHaveBeenLastCalledWith(1056.5);
});
it('should not round step', async () => {
const { getByTestId } = renderControl({
field: {
...mockIntNumberField,
step: 5.25,
},
});
const inputWrapper = getByTestId('number-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveAttribute('step', '5.25');
});
it('should default to step of 1 if step is not set', async () => {
const { getByTestId } = renderControl({
field: {
...mockIntNumberField,
},
});
const inputWrapper = getByTestId('number-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveAttribute('step', '1');
});
});
});

View File

@ -0,0 +1,383 @@
/**
* @jest-environment jsdom
*/
import '@testing-library/jest-dom';
import ValidationErrorTypes from '@staticcms/core/constants/validationErrorTypes';
import { mockNumberField } from '@staticcms/test/data/fields.mock';
import validator from '../validator';
import type { NumberField } from '@staticcms/core/interface';
describe('validator number', () => {
const t = jest.fn();
beforeEach(() => {
t.mockReset();
t.mockReturnValue('mock translated text');
});
const mockRangeNumberField: NumberField = {
label: 'Number',
name: 'mock_number',
widget: 'number',
min: 10,
max: 20,
};
it('should ignore min and max if a pattern is given', () => {
expect(
validator({
field: { ...mockRangeNumberField, pattern: ['3', 'Must be three'] },
value: 5,
t,
}),
).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should ignore min and max if value is null or undefined', () => {
expect(
validator({
field: mockRangeNumberField,
value: null,
t,
}),
).toBeFalsy();
expect(
validator({
field: mockRangeNumberField,
value: undefined,
t,
}),
).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
describe('number value', () => {
it('should return no error if min and max are not set', () => {
expect(validator({ field: mockNumberField, value: 5, t })).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
describe('range', () => {
it('should return no error if value in range', () => {
expect(validator({ field: mockRangeNumberField, value: 15, t })).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should return range error if below min', () => {
expect(validator({ field: mockRangeNumberField, value: 5, t })).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.range', {
fieldLabel: 'Number',
minValue: 10,
maxValue: 20,
});
});
it('should return range error if above max', () => {
expect(validator({ field: mockRangeNumberField, value: 25, t })).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.range', {
fieldLabel: 'Number',
minValue: 10,
maxValue: 20,
});
});
it('should use field name in translation if label is not provided', () => {
expect(
validator({
field: {
...mockRangeNumberField,
label: undefined,
},
value: 5,
t,
}),
).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.range', {
fieldLabel: 'mock_number',
minValue: 10,
maxValue: 20,
});
});
});
describe('min only', () => {
const mockMinNumberField: NumberField = {
label: 'Number',
name: 'mock_number',
widget: 'number',
min: 10,
};
it('should return no error if value is greater than or equal to min', () => {
expect(validator({ field: mockMinNumberField, value: 10, t })).toBeFalsy();
expect(validator({ field: mockMinNumberField, value: 15, t })).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should return range error if below min', () => {
expect(validator({ field: mockMinNumberField, value: 5, t })).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.min', {
fieldLabel: 'Number',
minValue: 10,
});
});
it('should use field name in translation if label is not provided', () => {
expect(
validator({
field: {
...mockMinNumberField,
label: undefined,
},
value: 5,
t,
}),
).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.min', {
fieldLabel: 'mock_number',
minValue: 10,
});
});
});
describe('max only', () => {
const mockMaxNumberField: NumberField = {
label: 'Number',
name: 'mock_number',
widget: 'number',
max: 1,
};
it('should return no error if value is less than or equal to max', () => {
expect(validator({ field: mockMaxNumberField, value: 1, t })).toBeFalsy();
expect(validator({ field: mockMaxNumberField, value: 0, t })).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should return range error if above max', () => {
expect(validator({ field: mockMaxNumberField, value: 5, t })).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.max', {
fieldLabel: 'Number',
maxValue: 1,
});
});
it('should use field name in translation if label is not provided', () => {
expect(
validator({
field: {
...mockMaxNumberField,
label: undefined,
},
value: 5,
t,
}),
).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.max', {
fieldLabel: 'mock_number',
maxValue: 1,
});
});
});
});
describe('string value', () => {
it('should return no error if min and max are not set', () => {
expect(validator({ field: mockNumberField, value: '5', t })).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
describe('range', () => {
const mockRangeNumberField: NumberField = {
label: 'Number',
name: 'mock_number',
widget: 'number',
min: 10,
max: 20,
};
it('should return no error if value in range', () => {
expect(validator({ field: mockRangeNumberField, value: '15', t })).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should return range error if below min', () => {
expect(validator({ field: mockRangeNumberField, value: '5', t })).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.range', {
fieldLabel: 'Number',
minValue: 10,
maxValue: 20,
});
});
it('should return range error if above max', () => {
expect(validator({ field: mockRangeNumberField, value: '25', t })).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.range', {
fieldLabel: 'Number',
minValue: 10,
maxValue: 20,
});
});
it('should use field name in translation if label is not provided', () => {
expect(
validator({
field: {
...mockRangeNumberField,
label: undefined,
},
value: '5',
t,
}),
).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.range', {
fieldLabel: 'mock_number',
minValue: 10,
maxValue: 20,
});
});
});
describe('min only', () => {
const mockMinNumberField: NumberField = {
label: 'Number',
name: 'mock_number',
widget: 'number',
min: 10,
};
it('should return no error if value is greater than or equal to min', () => {
expect(validator({ field: mockMinNumberField, value: 10, t })).toBeFalsy();
expect(validator({ field: mockMinNumberField, value: 15, t })).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should return range error if below min', () => {
expect(validator({ field: mockMinNumberField, value: '5', t })).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.min', {
fieldLabel: 'Number',
minValue: 10,
});
});
it('should use field name in translation if label is not provided', () => {
expect(
validator({
field: {
...mockMinNumberField,
label: undefined,
},
value: '5',
t,
}),
).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.min', {
fieldLabel: 'mock_number',
minValue: 10,
});
});
});
describe('max only', () => {
const mockMaxNumberField: NumberField = {
label: 'Number',
name: 'mock_number',
widget: 'number',
max: 1,
};
it('should return no error if value is less than or equal to max', () => {
expect(validator({ field: mockMaxNumberField, value: 1, t })).toBeFalsy();
expect(validator({ field: mockMaxNumberField, value: 0, t })).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should return range error if above max', () => {
expect(validator({ field: mockMaxNumberField, value: '5', t })).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.max', {
fieldLabel: 'Number',
maxValue: 1,
});
});
it('should use field name in translation if label is not provided', () => {
expect(
validator({
field: {
...mockMaxNumberField,
label: undefined,
},
value: '5',
t,
}),
).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.max', {
fieldLabel: 'mock_number',
maxValue: 1,
});
});
});
});
});

View File

@ -1,6 +1,7 @@
import controlComponent, { validateNumberMinMax } from './NumberControl';
import controlComponent from './NumberControl';
import previewComponent from './NumberPreview';
import schema from './schema';
import validator from './validator';
import type { NumberField, WidgetParam } from '@staticcms/core/interface';
@ -10,20 +11,7 @@ const NumberWidget = (): WidgetParam<string | number, NumberField> => {
controlComponent,
previewComponent,
options: {
validator: ({ field, value, t }) => {
// Pattern overrides min/max logic always:
const hasPattern = !!field.pattern ?? false;
if (hasPattern || !value) {
return false;
}
const min = field.min ?? false;
const max = field.max ?? false;
const error = validateNumberMinMax(value, min, max, field, t);
return error ?? false;
},
validator,
schema,
},
};
@ -32,8 +20,8 @@ const NumberWidget = (): WidgetParam<string | number, NumberField> => {
export {
controlComponent as NumberControl,
previewComponent as NumberPreview,
schema as NumberSchema,
validateNumberMinMax,
schema as numberSchema,
validator as numberValidator,
};
export default NumberWidget;

View File

@ -0,0 +1,71 @@
import ValidationErrorTypes from '@staticcms/core/constants/validationErrorTypes';
import type { FieldError, FieldValidationMethod, NumberField } from '@staticcms/core/interface';
import type { t } from 'react-polyglot';
function validateMinMax(
value: string | number,
min: number | false,
max: number | false,
field: NumberField,
t: t,
): FieldError | false {
let error: FieldError | false;
const numberValue = typeof value === 'string' ? parseFloat(value) : value;
switch (true) {
case !isNaN(numberValue) &&
min !== false &&
max !== false &&
(numberValue < min || numberValue > max):
error = {
type: ValidationErrorTypes.RANGE,
message: t('editor.editorControlPane.widget.range', {
fieldLabel: field.label ?? field.name,
minValue: min,
maxValue: max,
}),
};
break;
case !isNaN(numberValue) && min !== false && numberValue < min:
error = {
type: ValidationErrorTypes.RANGE,
message: t('editor.editorControlPane.widget.min', {
fieldLabel: field.label ?? field.name,
minValue: min,
}),
};
break;
case !isNaN(numberValue) && max !== false && numberValue > max:
error = {
type: ValidationErrorTypes.RANGE,
message: t('editor.editorControlPane.widget.max', {
fieldLabel: field.label ?? field.name,
maxValue: max,
}),
};
break;
default:
error = false;
break;
}
return error;
}
const validator: FieldValidationMethod<string | number, NumberField> = ({ field, value, t }) => {
// Pattern overrides min/max logic always
const hasPattern = !!field.pattern ?? false;
if (hasPattern || !value) {
return false;
}
const min = field.min ?? false;
const max = field.max ?? false;
return validateMinMax(value, min, max, field, t);
};
export default validator;

View File

@ -1,98 +1,46 @@
import { styled } from '@mui/material/styles';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useMemo } from 'react';
import EditorControl from '@staticcms/core/components/editor/EditorControlPane/EditorControl';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import { transientOptions } from '@staticcms/core/lib';
import EditorControl from '@staticcms/core/components/entry-editor/editor-control-pane/EditorControl';
import useHasChildErrors from '@staticcms/core/lib/hooks/useHasChildErrors';
import { compileStringTemplate } from '@staticcms/core/lib/widgets/stringTemplate';
import { getEntryDataPath } from '@staticcms/core/reducers/selectors/entryDraft';
import ObjectFieldWrapper from './ObjectFieldWrapper';
import type { ObjectField, ObjectValue, WidgetControlProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const StyledObjectControlWrapper = styled('div')`
position: relative;
background: white;
width: 100%;
`;
interface StyledFieldsBoxProps {
$collapsed: boolean;
}
const StyledFieldsBox = styled(
'div',
transientOptions,
)<StyledFieldsBoxProps>(
({ $collapsed }) => `
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
${
$collapsed
? `
display: none;
`
: `
padding: 16px;
`
}
`,
);
const StyledNoFieldsMessage = styled('div')`
display: flex;
padding: 16px;
width: 100%;
`;
const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
label,
field,
fieldsErrors,
submitted,
forList,
isDuplicate,
isFieldDuplicate,
isHidden,
isFieldHidden,
forSingleList,
duplicate,
hidden,
locale,
path,
t,
i18n,
hasErrors,
errors,
disabled,
value = {},
}) => {
const [collapsed, setCollapsed] = useState(field.collapsed ?? false);
const handleCollapseToggle = useCallback(() => {
setCollapsed(!collapsed);
}, [collapsed]);
const objectLabel = useMemo(() => {
const label = field.label ?? field.name;
const summary = field.summary;
return summary ? `${label} - ${compileStringTemplate(summary, null, '', value)}` : label;
}, [field.label, field.name, field.summary, value]);
}, [field.summary, label, value]);
const multiFields = useMemo(() => field.fields, [field.fields]);
const fields = useMemo(() => field.fields, [field.fields]);
const childHasError = useMemo(() => {
const dataPath = getEntryDataPath(i18n);
const fullPath = `${dataPath}.${path}`;
return Boolean(Object.keys(fieldsErrors).find(key => key.startsWith(fullPath)));
}, [fieldsErrors, i18n, path]);
const hasChildErrors = useHasChildErrors(path, fieldsErrors, i18n);
const renderedField = useMemo(() => {
return (
multiFields?.map((field, index) => {
fields?.map((field, index) => {
let fieldName = field.name;
let parentPath = path;
const fieldValue = value && value[fieldName];
if (forList && multiFields.length === 1) {
if (forList && fields.length === 1) {
const splitPath = path.split('.');
fieldName = splitPath.pop() ?? field.name;
parentPath = splitPath.join('.');
@ -107,61 +55,54 @@ const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
fieldsErrors={fieldsErrors}
submitted={submitted}
parentPath={parentPath}
isDisabled={isDuplicate}
isParentDuplicate={isDuplicate}
isFieldDuplicate={isFieldDuplicate}
isParentHidden={isHidden}
isFieldHidden={isFieldHidden}
disabled={disabled || duplicate}
parentDuplicate={duplicate}
parentHidden={hidden}
locale={locale}
i18n={i18n}
forList={forList}
forSingleList={forSingleList}
/>
);
}) ?? null
);
}, [
fieldsErrors,
forList,
i18n,
isDuplicate,
isFieldDuplicate,
isFieldHidden,
isHidden,
locale,
multiFields,
fields,
path,
submitted,
value,
forList,
fieldsErrors,
submitted,
disabled,
duplicate,
hidden,
locale,
i18n,
forSingleList,
]);
if (multiFields) {
if (fields.length) {
if (forList) {
return <>{renderedField}</>;
}
return (
<StyledObjectControlWrapper key="object-control-wrapper">
{forList ? null : (
<ObjectWidgetTopBar
key="object-control-top-bar"
collapsed={collapsed}
onCollapseToggle={handleCollapseToggle}
heading={objectLabel}
hasError={hasErrors || childHasError}
t={t}
testId="object-title"
/>
)}
<StyledFieldsBox $collapsed={collapsed} key="object-control-fields">
{renderedField}
</StyledFieldsBox>
{forList ? null : (
<Outline key="object-control-outline" hasError={hasErrors || childHasError} />
)}
</StyledObjectControlWrapper>
<ObjectFieldWrapper
key="object-control-wrapper"
field={field}
openLabel={label}
closedLabel={objectLabel}
errors={errors}
hasChildErrors={hasChildErrors}
hint={field.hint}
disabled={disabled}
>
{renderedField}
</ObjectFieldWrapper>
);
}
return (
<StyledNoFieldsMessage key="no-fields-found">
No field(s) defined for this widget
</StyledNoFieldsMessage>
);
return <div key="no-fields-found">No field(s) defined for this widget</div>;
};
export default ObjectControl;

View File

@ -0,0 +1,142 @@
import Collapse from '@mui/material/Collapse';
import { ChevronRight as ChevronRightIcon } from '@styled-icons/material/ChevronRight';
import React, { useCallback, useMemo, useState } from 'react';
import ErrorMessage from '@staticcms/core/components/common/field/ErrorMessage';
import Hint from '@staticcms/core/components/common/field/Hint';
import Label from '@staticcms/core/components/common/field/Label';
import classNames from '@staticcms/core/lib/util/classNames.util';
import type { FieldError, ObjectField } from '@staticcms/core/interface';
import type { FC, ReactNode } from 'react';
export interface ObjectFieldWrapperProps {
field: ObjectField;
openLabel: string;
closedLabel: string;
children: ReactNode | ReactNode[];
errors: FieldError[];
hasChildErrors: boolean;
hint?: string;
disabled: boolean;
}
const ObjectFieldWrapper: FC<ObjectFieldWrapperProps> = ({
field,
openLabel,
closedLabel,
children,
errors,
hasChildErrors,
hint,
disabled,
}) => {
const hasErrors = useMemo(() => errors.length > 0, [errors.length]);
const [open, setOpen] = useState(!field.collapsed ?? true);
const handleOpenToggle = useCallback(() => {
setOpen(oldOpen => !oldOpen);
}, []);
return (
<div
data-testid="object-field"
className={classNames(
`
relative
flex
flex-col
border-b
border-slate-400
focus-within:border-blue-800
dark:focus-within:border-blue-100
`,
!(hasErrors || hasChildErrors) && 'group/active-object',
)}
>
<button
data-testid="expand-button"
className="
flex
w-full
justify-between
pl-2
pr-3
py-2
text-left
text-sm
font-medium
focus:outline-none
focus-visible:ring
gap-2
focus-visible:ring-opacity-75
"
onClick={handleOpenToggle}
>
<ChevronRightIcon
className={classNames(
open && 'rotate-90 transform',
`
transition-transform
h-5
w-5
`,
disabled
? `
text-slate-300
dark:text-slate-600
`
: `
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500
`,
)}
/>
<Label
key="label"
hasErrors={hasErrors || hasChildErrors}
className={classNames(
!disabled &&
`
group-focus-within/active-object:text-blue-500
group-hover/active-object:text-blue-500
`,
)}
cursor="pointer"
variant="inline"
disabled={disabled}
>
{open ? openLabel : closedLabel}
</Label>
</button>
<Collapse in={open} appear={false}>
<div
data-testid="object-fields"
className={classNames(
`
ml-4
text-sm
text-gray-500
border-l-2
border-solid
border-l-slate-400
`,
!disabled && 'group-focus-within/active-object:border-l-blue-500',
(hasErrors || hasChildErrors) && 'border-l-red-500',
)}
>
{children}
</div>
</Collapse>
{hint ? (
<Hint key="hint" hasErrors={hasErrors} cursor="pointer" disabled={disabled}>
{hint}
</Hint>
) : null}
<ErrorMessage errors={errors} />
</div>
);
};
export default ObjectFieldWrapper;

View File

@ -1,12 +1,10 @@
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import type { ObjectField, ObjectValue, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const ObjectPreview: FC<WidgetPreviewProps<ObjectValue, ObjectField>> = ({ field }) => {
return <WidgetPreviewContainer>{field.renderedFields ?? null}</WidgetPreviewContainer>;
return <div>{field.renderedFields ?? null}</div>;
};
export default ObjectPreview;

Some files were not shown because too many files have changed in this diff Show More