feat: ui overhaul (#676)
This commit is contained in:
committed by
GitHub
parent
5c86462859
commit
66b81e9228
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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)',
|
||||
);
|
||||
});
|
||||
});
|
41
packages/core/src/widgets/datetime/components/NowButton.tsx
Normal file
41
packages/core/src/widgets/datetime/components/NowButton.tsx
Normal 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;
|
3
packages/core/src/widgets/datetime/constants.ts
Normal file
3
packages/core/src/widgets/datetime/constants.ts
Normal 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";
|
32
packages/core/src/widgets/datetime/getDefaultValue.ts
Normal file
32
packages/core/src/widgets/datetime/getDefaultValue.ts
Normal 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;
|
28
packages/core/src/widgets/datetime/index.ts
Normal file
28
packages/core/src/widgets/datetime/index.ts
Normal 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;
|
@ -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;
|
13
packages/core/src/widgets/datetime/utc.util.ts
Normal file
13
packages/core/src/widgets/datetime/utc.util.ts
Normal 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();
|
||||
}
|
@ -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;
|
||||
|
276
packages/core/src/widgets/file/__test__/FileControl.spec.ts
Normal file
276
packages/core/src/widgets/file/__test__/FileControl.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
44
packages/core/src/widgets/file/components/SortableImage.tsx
Normal file
44
packages/core/src/widgets/file/components/SortableImage.tsx
Normal 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;
|
@ -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],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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';
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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
151
packages/core/src/widgets/list/components/ListFieldWrapper.tsx
Normal file
151
packages/core/src/widgets/list/components/ListFieldWrapper.tsx
Normal 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;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
241
packages/core/src/widgets/list/components/ListItemWrapper.tsx
Normal file
241
packages/core/src/widgets/list/components/ListItemWrapper.tsx
Normal 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;
|
@ -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 };
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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]);
|
||||
|
@ -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],
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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();
|
||||
|
@ -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;
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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 />} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
</>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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';
|
||||
|
@ -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')}
|
||||
/>
|
||||
) : (
|
||||
<> </>
|
||||
)}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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<{}>>;
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
@ -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];
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
[
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
261
packages/core/src/widgets/number/__tests__/NumberControl.spec.ts
Normal file
261
packages/core/src/widgets/number/__tests__/NumberControl.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
383
packages/core/src/widgets/number/__tests__/validator.spec.ts
Normal file
383
packages/core/src/widgets/number/__tests__/validator.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
|
71
packages/core/src/widgets/number/validator.ts
Normal file
71
packages/core/src/widgets/number/validator.ts
Normal 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;
|
@ -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;
|
||||
|
142
packages/core/src/widgets/object/ObjectFieldWrapper.tsx
Normal file
142
packages/core/src/widgets/object/ObjectFieldWrapper.tsx
Normal 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;
|
@ -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
Reference in New Issue
Block a user