Bugfixes - Editor images, git-gateway auth, editor scrolling, numeric selects, object/list previews (#50)

* v1.0.0-alpha26
This commit is contained in:
Daniel Lautzenheiser
2022-10-27 12:24:30 -04:00
committed by GitHub
parent 1de3d52d57
commit 8c8a59093d
47 changed files with 794 additions and 942 deletions

View File

@ -64,8 +64,11 @@ export function authenticateUser() {
dispatch(doneAuthenticating());
}
})
.catch((error: Error) => {
dispatch(authError(error));
.catch((error: unknown) => {
console.error(error);
if (error instanceof Error) {
dispatch(authError(error));
}
dispatch(logoutUser());
});
};

View File

@ -386,7 +386,7 @@ export async function handleLocalBackend(originalConfig: Config) {
});
}
export function loadConfig(manualConfig: Config | undefined, onLoad: () => unknown) {
export function loadConfig(manualConfig: Config | undefined, onLoad: (config: Config) => unknown) {
if (window.CMS_CONFIG) {
return configLoaded(window.CMS_CONFIG);
}
@ -405,7 +405,7 @@ export function loadConfig(manualConfig: Config | undefined, onLoad: () => unkno
dispatch(configLoaded(config));
if (typeof onLoad === 'function') {
onLoad();
onLoad(config);
}
} catch (error: unknown) {
console.error(error);

View File

@ -76,9 +76,14 @@ const emptyAsset = createAssetProxy({
}),
});
export function getAsset(collection: Collection, entry: Entry, path: string, field?: Field) {
export function getAsset(
collection: Collection | null | undefined,
entry: Entry | null | undefined,
path: string,
field?: Field,
) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
if (!path) {
if (!collection || !entry || !path) {
return emptyAsset;
}

View File

@ -1,25 +1,9 @@
import Button from '@mui/material/Button';
import { styled } from '@mui/material/styles';
import TextField from '@mui/material/TextField';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import AuthenticationPage from '../../components/UI/AuthenticationPage';
import { colors } from '../../components/UI/styles';
import type { ChangeEvent, FormEvent } from 'react';
import type { AuthenticationPageProps, TranslatedProps, User } from '../../interface';
const StyledAuthForm = styled('form')`
width: 350px;
display: flex;
flex-direction: column;
gap: 16px;
`;
const ErrorMessage = styled('div')`
color: ${colors.errorText};
`;
function useNetlifyIdentifyEvent(eventName: 'login', callback: (login: User) => void): void;
function useNetlifyIdentifyEvent(eventName: 'logout', callback: () => void): void;
function useNetlifyIdentifyEvent(eventName: 'error', callback: (err: Error) => void): void;
@ -39,15 +23,12 @@ export interface GitGatewayAuthenticationPageProps
}
const GitGatewayAuthenticationPage = ({
inProgress = false,
config,
onLogin,
handleAuth,
t,
}: GitGatewayAuthenticationPageProps) => {
const [loggingIn, setLoggingIn] = useState(false);
const [loggedIn, setLoggedIn] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{
identity?: string;
server?: string;
@ -57,16 +38,28 @@ const GitGatewayAuthenticationPage = ({
useEffect(() => {
if (!loggedIn && window.netlifyIdentity && window.netlifyIdentity.currentUser()) {
onLogin(window.netlifyIdentity.currentUser());
window.netlifyIdentity.close();
setLoggingIn(true);
setTimeout(() => {
if (!window.netlifyIdentity) {
setLoggingIn(false);
return;
}
onLogin(window.netlifyIdentity.currentUser());
setLoggedIn(true);
window.netlifyIdentity.close();
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleIdentityLogin = useCallback(
(user: User) => {
onLogin(user);
window.netlifyIdentity?.close();
setLoggingIn(true);
setTimeout(() => {
onLogin(user);
setLoggedIn(true);
window.netlifyIdentity?.close();
});
},
[onLogin],
);
@ -94,130 +87,45 @@ const GitGatewayAuthenticationPage = ({
const handleIdentity = useCallback(() => {
const user = window.netlifyIdentity?.currentUser();
if (user) {
onLogin(user);
setLoggingIn(true);
setTimeout(() => {
onLogin(user);
setLoggedIn(true);
});
} else {
window.netlifyIdentity?.open();
}
}, [onLogin]);
const handleEmailChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setEmail(event.target.value);
}, []);
const pageContent = useMemo(() => {
if (!window.netlifyIdentity) {
return t('auth.errors.netlifyIdentityNotFound');
}
const handlePasswordChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value);
}, []);
const handleLogin = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const validationErrors: typeof errors = {};
if (!email) {
validationErrors.email = t('auth.errors.email');
}
if (!password) {
validationErrors.password = t('auth.errors.password');
}
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
let response: User | string;
try {
response = await handleAuth(email, password);
} catch (e: unknown) {
if (e instanceof Error) {
response = e.message;
} else {
response = 'Unknown authentication error';
}
}
if (typeof response === 'string') {
setErrors({ server: response });
setLoggedIn(false);
return;
}
onLogin(response);
},
[email, handleAuth, onLogin, password, t],
);
if (window.netlifyIdentity) {
if (errors.identity) {
return (
<AuthenticationPage
logoUrl={config.logo_url}
siteUrl={config.site_url}
onLogin={handleIdentity}
pageContent={
<a
href="https://docs.netlify.com/visitor-access/git-gateway/#setup-and-settings"
target="_blank"
rel="noopener noreferrer"
>
{errors.identity}
</a>
}
t={t}
/>
);
} else {
return (
<AuthenticationPage
logoUrl={config.logo_url}
siteUrl={config.site_url}
onLogin={handleIdentity}
buttonContent={t('auth.loginWithNetlifyIdentity')}
t={t}
/>
<a
href="https://docs.netlify.com/visitor-access/git-gateway/#setup-and-settings"
target="_blank"
rel="noopener noreferrer"
>
{errors.identity}
</a>
);
}
}
return null;
}, [errors.identity, t]);
return (
<AuthenticationPage
key="git-gateway-auth"
logoUrl={config.logo_url}
siteUrl={config.site_url}
pageContent={
<StyledAuthForm onSubmit={handleLogin}>
{!errors.server ? null : <ErrorMessage>{String(errors.server)}</ErrorMessage>}
<TextField
type="text"
name="email"
label="Email"
value={email}
onChange={handleEmailChange}
fullWidth
variant="outlined"
error={Boolean(errors.email)}
helperText={errors.email ?? undefined}
/>
<TextField
type="password"
name="password"
label="Password"
value={password}
onChange={handlePasswordChange}
fullWidth
variant="outlined"
error={Boolean(errors.password)}
helperText={errors.password ?? undefined}
/>
<Button
variant="contained"
type="submit"
disabled={inProgress}
sx={{ width: 120, alignSelf: 'center' }}
>
{inProgress ? t('auth.loggingIn') : t('auth.login')}
</Button>
</StyledAuthForm>
}
onLogin={handleIdentity}
buttonContent={t('auth.loginWithNetlifyIdentity')}
pageContent={pageContent}
loginDisabled={loggingIn}
t={t}
/>
);

View File

@ -1,10 +1,9 @@
import React, { useCallback } from 'react';
import GoTrue from 'gotrue-js';
import ini from 'ini';
import jwtDecode from 'jwt-decode';
import get from 'lodash/get';
import intersection from 'lodash/intersection';
import pick from 'lodash/pick';
import React, { useCallback } from 'react';
import {
AccessTokenError,
@ -25,22 +24,22 @@ import GitHubAPI from './GitHubAPI';
import GitLabAPI from './GitLabAPI';
import { getClient } from './netlify-lfs-client';
import type { ApiRequest, Cursor } from '../../lib/util';
import type {
AuthenticationPageProps,
BackendClass,
BackendEntry,
Config,
Credentials,
DisplayURL,
DisplayURLObject,
BackendEntry,
BackendClass,
ImplementationFile,
PersistOptions,
User,
TranslatedProps,
AuthenticationPageProps,
User,
} from '../../interface';
import type { Client } from './netlify-lfs-client';
import type { ApiRequest, Cursor } from '../../lib/util';
import type AssetProxy from '../../valueObjects/AssetProxy';
import type { Client } from './netlify-lfs-client';
const STATUS_PAGE = 'https://www.netlifystatus.com';
const GIT_GATEWAY_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
@ -224,33 +223,18 @@ export default class GitGateway implements BackendClass {
return this.authClient;
}
await initPromise;
if (window.netlifyIdentity) {
this.authClient = {
logout: () => window.netlifyIdentity?.logout(),
currentUser: () => window.netlifyIdentity?.currentUser(),
clearStore: () => {
const store = window.netlifyIdentity?.store;
if (store) {
store.user = null;
store.modal.page = 'login';
store.saving = false;
}
},
};
} else {
const goTrue = new GoTrue({ APIUrl: this.apiUrl });
this.authClient = {
logout: () => {
const user = goTrue.currentUser();
if (user) {
return user.logout();
}
},
currentUser: () => goTrue.currentUser(),
login: goTrue.login.bind(goTrue),
clearStore: () => undefined,
};
}
this.authClient = {
logout: () => window.netlifyIdentity?.logout(),
currentUser: () => window.netlifyIdentity?.currentUser(),
clearStore: () => {
const store = window.netlifyIdentity?.store;
if (store) {
store.user = null;
store.modal.page = 'login';
store.saving = false;
}
},
};
}
requestFunction = (req: ApiRequest) =>

View File

@ -228,7 +228,7 @@ export default class TestBackend implements BackendClass {
async getMediaFile(path: string) {
const asset = getFile(path, window.repoFiles).content as AssetProxy;
const url = asset.toString();
const url = asset?.toString() ?? '';
const name = basename(path);
const blob = await fetch(url).then(res => res.blob());
const fileObj = new File([blob], name);

View File

@ -86,8 +86,10 @@ function bootstrap(opts?: { config?: Config; autoInitialize?: boolean }) {
}
store.dispatch(
loadConfig(config, function onLoad() {
store.dispatch(authenticateUser() as unknown as AnyAction);
loadConfig(config, function onLoad(config) {
if (config.backend.name !== 'git-gateway') {
store.dispatch(authenticateUser() as unknown as AnyAction);
}
}) as AnyAction,
);

View File

@ -28,8 +28,6 @@ import { selectIsLoadingAsset } from '../../../reducers/medias';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
import type {
Collection,
Entry,
Field,
FieldsErrors,
GetAssetFunction,
@ -178,12 +176,12 @@ const EditorControl = ({
const errors = useMemo(() => fieldsErrors[path] ?? [], [fieldsErrors, path]);
const hasErrors = (submitted || dirty) && Boolean(errors.length);
const handleGetAsset = useCallback(
(collection: Collection, entry: Entry): GetAssetFunction =>
(path: string, field?: Field) => {
return getAsset(collection, entry, path, field);
},
[getAsset],
const handleGetAsset: GetAssetFunction = useMemo(
() => (path: string, field?: Field) => {
return getAsset(collection, entry, path, field);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[collection],
);
useEffect(() => {
@ -211,6 +209,7 @@ const EditorControl = ({
<ControlContainer className={className} $isHidden={isHidden}>
<>
{React.createElement(widget.control, {
key: `field_${path}`,
clearFieldErrors,
clearSearch,
collection,
@ -219,7 +218,7 @@ const EditorControl = ({
field,
fieldsErrors,
submitted,
getAsset: handleGetAsset(collection, entry),
getAsset: handleGetAsset,
isDisabled: isDisabled ?? false,
isFetching,
isFieldDuplicate,
@ -241,9 +240,13 @@ const EditorControl = ({
i18n,
hasErrors,
})}
{fieldHint && <ControlHint $error={hasErrors}>{fieldHint}</ControlHint>}
{fieldHint ? (
<ControlHint key="hint" $error={hasErrors}>
{fieldHint}
</ControlHint>
) : null}
{hasErrors ? (
<ControlErrorsList>
<ControlErrorsList key="errors">
{errors.map(error => {
return (
error.message &&

View File

@ -193,11 +193,11 @@ const EditorControlPane = ({
) : null}
{fields
.filter(f => f.widget !== 'hidden')
.map((field, i) => {
.map(field => {
const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale);
const isDuplicate = isFieldDuplicate(field, locale, i18n?.defaultLocale);
const isHidden = isFieldHidden(field, locale, i18n?.defaultLocale);
const key = i18n ? `${locale}_${i}` : i;
const key = i18n ? `field-${locale}_${field.name}` : `field-${field.name}`;
return (
<EditorControl

View File

@ -3,12 +3,12 @@ import React, { useMemo } from 'react';
import type { ReactNode } from 'react';
import type { TemplatePreviewComponent, TemplatePreviewProps } from '../../../interface';
interface PreviewContentProps {
interface EditorPreviewContentProps {
previewComponent?: TemplatePreviewComponent;
previewProps: TemplatePreviewProps;
}
const PreviewContent = ({ previewComponent, previewProps }: PreviewContentProps) => {
const EditorPreviewContent = ({ previewComponent, previewProps }: EditorPreviewContentProps) => {
return useMemo(() => {
let children: ReactNode;
if (!previewComponent) {
@ -23,4 +23,4 @@ const PreviewContent = ({ previewComponent, previewProps }: PreviewContentProps)
}, [previewComponent, previewProps]);
};
export default PreviewContent;
export default EditorPreviewContent;

View File

@ -8,11 +8,11 @@ import { ScrollSyncPane } from 'react-scroll-sync';
import { getAsset as getAssetAction } from '../../../actions/media';
import { lengths } from '../../../components/UI/styles';
import { INFERABLE_FIELDS } from '../../../constants/fieldInference';
import { getPreviewStyles, getPreviewTemplate, resolveWidget } from '../../../lib/registry';
import { selectInferedField, selectTemplateName } from '../../../lib/util/collection.util';
import { selectTemplateName, useInferedFields } from '../../../lib/util/collection.util';
import { selectField } from '../../../lib/util/field.util';
import { selectIsLoadingAsset } from '../../../reducers/medias';
import { getTypedFieldForValue } from '../../../widgets/list/typedListHelpers';
import { ErrorBoundary } from '../../UI';
import EditorPreview from './EditorPreview';
import EditorPreviewContent from './EditorPreviewContent';
@ -27,6 +27,8 @@ import type {
EntryData,
Field,
GetAssetFunction,
ListField,
RenderedField,
TemplatePreviewProps,
TranslatedProps,
ValueOrNestedValue,
@ -42,6 +44,20 @@ const PreviewPaneFrame = styled(Frame)`
overflow: auto;
`;
const FrameGlobalStyles = `
body {
margin: 0;
}
img {
max-width: 100%;
}
.frame-content {
padding: 16px;
}
`;
const PreviewPaneWrapper = styled('div')`
width: 100%;
height: 100%;
@ -49,6 +65,7 @@ const PreviewPaneWrapper = styled('div')`
background: #fff;
border-radius: ${lengths.borderRadius};
overflow: auto;
padding: 16px;
`;
const StyledPreviewContent = styled('div')`
@ -57,8 +74,7 @@ const StyledPreviewContent = styled('div')`
right: 0;
position: absolute;
height: calc(100vh - 64px);
overflow-y: auto;
padding: 16px;
overflow: hidden;
`;
/**
@ -85,10 +101,7 @@ function getWidgetFor(
}
const value = values?.[field.name];
let fieldWithWidgets: Omit<Field, 'fields' | 'field'> & {
fields?: ReactNode[];
field?: ReactNode;
} = Object.entries(field).reduce((acc, [key, fieldValue]) => {
let fieldWithWidgets: RenderedField = Object.entries(field).reduce((acc, [key, fieldValue]) => {
if (!['fields', 'fields'].includes(key)) {
acc[key] = fieldValue;
}
@ -108,6 +121,18 @@ function getWidgetFor(
value as EntryData | EntryData[],
),
};
} else if ('types' in field && field.types) {
fieldWithWidgets = {
...fieldWithWidgets,
fields: getTypedNestedWidgets(
collection,
field,
entry,
inferedFields,
getAsset,
value as EntryData[],
),
};
}
const labelledWidgets = ['string', 'text', 'number'];
@ -135,7 +160,9 @@ function getWidgetFor(
</div>
);
}
return renderedValue ? getWidget(field, renderedValue, entry, getAsset) : null;
return renderedValue
? getWidget(fieldWithWidgets, collection, renderedValue, entry, getAsset)
: null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -153,7 +180,8 @@ function isReactFragment(value: any): value is ReactFragment {
}
function getWidget(
field: Field,
field: RenderedField,
collection: Collection,
value: ValueOrNestedValue | ReactNode,
entry: Entry,
getAsset: GetAssetFunction,
@ -175,6 +203,7 @@ function getWidget(
key={key}
field={field}
getAsset={getAsset}
collection={collection}
value={
value &&
!widget.allowMapValue &&
@ -218,6 +247,37 @@ function widgetsForNestedFields(
.filter(widget => Boolean(widget)) as JSX.Element[];
}
/**
* Retrieves widgets for nested fields (children of object/list fields)
*/
function getTypedNestedWidgets(
collection: Collection,
field: ListField,
entry: Entry,
inferedFields: Record<string, InferredField>,
getAsset: GetAssetFunction,
values: EntryData[],
) {
return values
.flatMap((value, index) => {
const itemType = getTypedFieldForValue(field, value ?? {}, index);
if (!itemType) {
return null;
}
return widgetsForNestedFields(
collection,
itemType.fields,
entry,
inferedFields,
getAsset,
itemType.fields,
value,
);
})
.filter(Boolean);
}
/**
* Retrieves widgets for nested fields (children of object/list fields)
*/
@ -260,29 +320,21 @@ function getNestedWidgets(
const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
const { entry, collection, config, fields, previewInFrame, getAsset, t } = props;
const inferedFields = useMemo(() => {
const titleField = selectInferedField(collection, 'title');
const shortTitleField = selectInferedField(collection, 'shortTitle');
const authorField = selectInferedField(collection, 'author');
const iFields: Record<string, InferredField> = {};
if (titleField) {
iFields[titleField] = INFERABLE_FIELDS.title;
}
if (shortTitleField) {
iFields[shortTitleField] = INFERABLE_FIELDS.shortTitle;
}
if (authorField) {
iFields[authorField] = INFERABLE_FIELDS.author;
}
return iFields;
}, [collection]);
const inferedFields = useInferedFields(collection);
const handleGetAsset = useCallback(
(path: string, field?: Field) => {
return getAsset(collection, entry, path, field);
},
[collection, entry, getAsset],
// eslint-disable-next-line react-hooks/exhaustive-deps
[collection],
);
const widgetFor = useCallback(
(name: string) => {
return getWidgetFor(collection, name, fields, entry, inferedFields, handleGetAsset);
},
[collection, entry, fields, handleGetAsset, inferedFields],
);
/**
@ -305,7 +357,7 @@ const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
if (Array.isArray(value)) {
return value.map(val => {
const widgets = nestedFields.reduce((acc, field, index) => {
acc[field.name] = <div key={index}>{getWidget(field, val, entry, handleGetAsset)}</div>;
acc[field.name] = <div key={index}>{widgetFor(field.name)}</div>;
return acc;
}, {} as Record<string, ReactNode>);
return { data: val, widgets };
@ -315,29 +367,24 @@ const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
return {
data: value,
widgets: nestedFields.reduce((acc, field, index) => {
acc[field.name] = <div key={index}>{getWidget(field, value, entry, handleGetAsset)}</div>;
acc[field.name] = <div key={index}>{widgetFor(field.name)}</div>;
return acc;
}, {} as Record<string, ReactNode>),
};
},
[entry, fields, handleGetAsset],
);
const widgetFor = useCallback(
(name: string) => {
return getWidgetFor(collection, name, fields, entry, inferedFields, handleGetAsset);
},
[collection, entry, fields, handleGetAsset, inferedFields],
[entry.data, fields, widgetFor],
);
const previewStyles = useMemo(
() =>
getPreviewStyles().map((style, i) => {
() => [
...getPreviewStyles().map((style, i) => {
if (style.raw) {
return <style key={i}>{style.value}</style>;
}
return <link key={i} href={style.value} type="text/css" rel="stylesheet" />;
}),
<style key="global">{FrameGlobalStyles}</style>,
],
[],
);

View File

@ -3,7 +3,7 @@ import React from 'react';
import type { WidgetPreviewComponent, WidgetPreviewProps } from '../../../interface';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface PreviewHOCProps extends WidgetPreviewProps {
interface PreviewHOCProps extends Omit<WidgetPreviewProps, 'widgetFor'> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
previewComponent: WidgetPreviewComponent;
}

View File

@ -172,7 +172,9 @@ const EditorToolbar = ({
if (canCreate) {
items.push(
<MenuItem key="duplicate" onClick={handleDuplicate}>{t('editor.editorToolbar.duplicate')}</MenuItem>,
<MenuItem key="duplicate" onClick={handleDuplicate}>
{t('editor.editorToolbar.duplicate')}
</MenuItem>,
);
}

View File

@ -7,11 +7,7 @@ import {
ProxyBackend,
TestBackend,
} from './backends';
import {
registerBackend,
registerLocale,
registerWidget,
} from './lib/registry';
import { registerBackend, registerLocale, registerWidget } from './lib/registry';
import { locales } from './locales';
import {
BooleanWidget,

View File

@ -1,5 +1,8 @@
import type { EditorPlugin, EditorType, WidgetRule } from '@toast-ui/editor/types/editor';
import type { ToolbarItemOptions } from '@toast-ui/editor/types/ui';
import type {
EditorPlugin as MarkdownPlugin,
EditorType as MarkdownEditorType,
} from '@toast-ui/editor/types/editor';
import type { ToolbarItemOptions as MarkdownToolbarItemOptions } from '@toast-ui/editor/types/ui';
import type { PropertiesSchema } from 'ajv/dist/types/json-schema';
import type { ComponentType, ReactNode } from 'react';
import type { t, TranslateProps as ReactPolyglotTranslateProps } from 'react-polyglot';
@ -259,8 +262,9 @@ export interface WidgetControlProps<T, F extends Field = Field> {
}
export interface WidgetPreviewProps<T = unknown, F extends Field = Field> {
collection: Collection;
entry: Entry;
field: F;
field: RenderedField<F>;
getAsset: GetAssetFunction;
resolveWidget: <W = unknown, WF extends Field = Field>(name: string) => Widget<W, WF>;
value: T | undefined | null;
@ -525,6 +529,10 @@ export type AuthScope = 'repo' | 'public_repo';
export type SlugEncoding = 'unicode' | 'ascii';
export type RenderedField<T extends Field = Field> = Omit<T, 'fields'> & {
fields?: ReactNode[];
};
export interface BaseField {
name: string;
label?: string;
@ -898,25 +906,25 @@ export interface PreviewStyle {
raw: boolean;
}
export interface WidgetRulesFactoryProps {
export interface MarkdownPluginFactoryProps {
getAsset: GetAssetFunction;
field: MarkdownField;
mode: 'editor' | 'preview';
}
export type WidgetRulesFactory = (props: WidgetRulesFactoryProps) => WidgetRule[];
export type MarkdownPluginFactory = (props: MarkdownPluginFactoryProps) => MarkdownPlugin;
export interface ToolbarItemsFactoryProps {
imageToolbarButton: ToolbarItemOptions;
export interface MarkdownToolbarItemsFactoryProps {
imageToolbarButton: MarkdownToolbarItemOptions;
}
export type ToolbarItemsFactory = (
props: ToolbarItemsFactoryProps,
) => (string | ToolbarItemOptions)[][];
export type MarkdownToolbarItemsFactory = (
props: MarkdownToolbarItemsFactoryProps,
) => (string | MarkdownToolbarItemOptions)[][];
export interface MarkdownEditorOptions {
widgetRules?: WidgetRulesFactory;
initialEditType?: EditorType;
initialEditType?: MarkdownEditorType;
height?: string;
toolbarItems?: ToolbarItemsFactory;
plugins?: EditorPlugin[];
toolbarItems?: MarkdownToolbarItemsFactory;
plugins?: MarkdownPluginFactory[];
}

View File

@ -133,7 +133,11 @@ export function registerWidget<T = unknown>(
name: string | WidgetParam<T> | WidgetParam[],
control?: string | Widget<T>['control'],
preview?: Widget<T>['preview'],
{ schema, validator, getValidValue }: WidgetOptions = {},
{
schema,
validator = () => false,
getValidValue = (value: unknown) => value,
}: WidgetOptions = {},
): void {
if (Array.isArray(name)) {
name.forEach(widget => {

View File

@ -1,4 +1,5 @@
import get from 'lodash/get';
import { useMemo } from 'react';
import { FILES, FOLDER } from '../../constants/collectionTypes';
import { COMMIT_AUTHOR, COMMIT_DATE } from '../../constants/commitProps';
@ -15,6 +16,7 @@ import { selectField } from './field.util';
import { selectMediaFolder } from './media.util';
import type { Backend } from '../../backend';
import type { InferredField } from '../../constants/fieldInference';
import type {
Collection,
CollectionFile,
@ -415,3 +417,23 @@ export function selectInferedField(collection: Collection, fieldName: string) {
return null;
}
export function useInferedFields(collection: Collection) {
return useMemo(() => {
const titleField = selectInferedField(collection, 'title');
const shortTitleField = selectInferedField(collection, 'shortTitle');
const authorField = selectInferedField(collection, 'author');
const iFields: Record<string, InferredField> = {};
if (titleField) {
iFields[titleField] = INFERABLE_FIELDS.title;
}
if (shortTitleField) {
iFields[shortTitleField] = INFERABLE_FIELDS.shortTitle;
}
if (authorField) {
iFields[authorField] = INFERABLE_FIELDS.author;
}
return iFields;
}, [collection]);
}

View File

@ -27,3 +27,38 @@ export function getFieldLabel(field: Field, t: t) {
field.required === false ? ` (${t('editor.editorControl.field.optional')})` : ''
}`}`;
}
function findField(field: Field | undefined, path: string[]): Field | null {
if (!field) {
return null;
}
if (path.length === 0) {
return field;
}
if (!('fields' in field && field.fields)) {
return null;
}
const name = path.slice(0, 1)[0];
const rest = path.slice(1);
return findField(
field.fields.find(f => f.name === name),
rest,
);
}
export function getField(field: Field | Field[], path: string): Field | null {
return findField(
Array.isArray(field)
? {
widget: 'object',
name: 'root',
fields: field,
}
: field,
path.split('.'),
);
}

View File

@ -3,7 +3,7 @@ import isNumber from 'lodash/isNumber';
export function validateMinMax(
t: (key: string, options: unknown) => string,
fieldLabel: string,
value?: string | string[] | undefined | null,
value?: string | number | (string | number)[] | undefined | null,
min?: number,
max?: number,
) {
@ -19,11 +19,17 @@ export function validateMinMax(
};
}
if ([min, max, value?.length].every(isNumber) && (value!.length < min! || value!.length > max!)) {
if (typeof value === 'string' || typeof value === 'number') {
return false;
}
const length = value?.length ?? 0;
if ([min, max, length].every(isNumber) && (length < min! || length > max!)) {
return minMaxError(min === max ? 'rangeCountExact' : 'rangeCount');
} else if (isNumber(min) && min > 0 && value?.length && value.length < min) {
} else if (isNumber(min) && min > 0 && length < min) {
return minMaxError('rangeMin');
} else if (isNumber(max) && value?.length && value.length > max) {
} else if (isNumber(max) && length > max) {
return minMaxError('rangeMax');
}
}

View File

@ -14,6 +14,7 @@ const en: LocalePhrasesRoot = {
password: 'Please enter your password.',
authTitle: 'Error logging in',
authBody: '%{details}',
netlifyIdentityNotFound: 'Netlify Identity plugin not found',
identitySettings:
'Unable to access identity settings. When using git-gateway backend make sure to enable Identity service and Git Gateway.',
},

View File

@ -14,7 +14,7 @@ export default class AssetProxy {
field?: Field;
constructor({ url, file, path, field }: AssetProxyArgs) {
this.url = url ? url : window.URL.createObjectURL(file as Blob);
this.url = url ? url : file ? window.URL.createObjectURL(file as Blob) : '';
this.fileObj = file;
this.path = path;
this.field = field;

View File

@ -39,9 +39,7 @@ const StyledCodeControlContent = styled(
${
$collapsed
? `
visibility: hidden;
height: 0;
width: 0;
display: none;
`
: ''
}

View File

@ -36,9 +36,7 @@ const StyledColorControlContent = styled(
${
$collapsed
? `
visibility: hidden;
height: 0;
width: 0;
display: none;
`
: `
padding: 16px;

View File

@ -1,20 +1,34 @@
import React from 'react';
import { styled } from '@mui/material/styles';
import React, { useEffect, useState } from 'react';
import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer';
import type { FileOrImageField, WidgetPreviewProps, GetAssetFunction } from '../../interface';
import type { FileOrImageField, GetAssetFunction, WidgetPreviewProps } from '../../interface';
interface FileLinkProps {
href: string;
path: string;
value: string;
getAsset: GetAssetFunction;
field: FileOrImageField;
}
const FileLink = styled(({ href, path }: FileLinkProps) => (
<a href={href} rel="noopener noreferrer" target="_blank">
{path}
</a>
))`
const FileLink = ({ value, getAsset, field }: FileLinkProps) => {
const [assetSource, setAssetSource] = useState('');
useEffect(() => {
if (!value || Array.isArray(value)) {
return;
}
setAssetSource(getAsset(value, field)?.toString() ?? '');
}, [field, getAsset, value]);
return (
<a href={assetSource} rel="noopener noreferrer" target="_blank">
{value}
</a>
);
};
const StyledFileLink = styled(FileLink)`
display: block;
`;
@ -28,7 +42,7 @@ function FileLinkList({ values, getAsset, field }: FileLinkListProps) {
return (
<div>
{values.map(value => (
<FileLink key={value} path={value} href={getAsset(value, field).toString()} />
<StyledFileLink key={value} value={value} getAsset={getAsset} field={field} />
))}
</div>
);
@ -47,7 +61,7 @@ function FileContent({
return <FileLinkList values={value} getAsset={getAsset} field={field} />;
}
return <FileLink key={value} path={value} href={getAsset(value, field).toString()} />;
return <StyledFileLink key={value} value={value} getAsset={getAsset} field={field} />;
}
function FilePreview(props: WidgetPreviewProps<string | string[], FileOrImageField>) {

View File

@ -3,8 +3,8 @@ 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 { arrayMoveImmutable as arrayMove } from 'array-move';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { arrayMoveImmutable } from 'array-move';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { SortableContainer, SortableElement } from 'react-sortable-hoc';
import uuid from 'uuid/v4';
@ -12,6 +12,7 @@ import ObjectWidgetTopBar from '../../components/UI/ObjectWidgetTopBar';
import Outline from '../../components/UI/Outline';
import { borders, effects, lengths, shadows } from '../../components/UI/styles';
import { basename, transientOptions } from '../../lib/util';
import { isEmpty } from '../../lib/util/string.util';
import type { MouseEvent, MouseEventHandler } from 'react';
import type { FileOrImageField, GetAssetFunction, WidgetControlProps } from '../../interface';
@ -40,9 +41,7 @@ const StyledFileControlContent = styled(
${
$collapsed
? `
visibility: hidden;
height: 0;
width: 0;
display: none;
`
: `
padding: 16px;
@ -135,10 +134,15 @@ interface SortableImageProps {
const SortableImage = SortableElement<SortableImageProps>(
({ itemValue, getAsset, field, onRemove, onReplace }: SortableImageProps) => {
const [assetSource, setAssetSource] = useState('');
useEffect(() => {
setAssetSource(getAsset(itemValue, field)?.toString() ?? '');
}, [field, getAsset, itemValue]);
return (
<div>
<ImageWrapper key="image-wrapper" $sortable>
<Image key="image" src={getAsset(itemValue, field)?.toString() ?? ''} />
<Image key="image" src={assetSource} />
</ImageWrapper>
<SortableImageButtons
key="image-buttons"
@ -215,35 +219,41 @@ export function getValidValue(value: string | string[] | null | undefined) {
return value;
}
function sizeOfValue(value: string | string[] | null | undefined) {
if (Array.isArray(value)) {
return value.length;
}
return value ? 1 : 0;
}
interface WithImageOptions {
forImage?: boolean;
}
export default function withFileControl({ forImage = false }: WithImageOptions = {}) {
const FileControl = ({
path,
value,
field,
onChange,
openMediaLibrary,
clearMediaControl,
removeInsertedMedia,
removeMediaControl,
getAsset,
mediaPaths,
hasErrors,
t,
}: WidgetControlProps<string | string[], FileOrImageField>) => {
const FileControl = memo((props: WidgetControlProps<string | string[], FileOrImageField>) => {
const {
value,
field,
onChange,
openMediaLibrary,
clearMediaControl,
removeInsertedMedia,
removeMediaControl,
getAsset,
mediaPaths,
hasErrors,
t,
} = props;
const controlID = useMemo(() => uuid(), []);
const [collapsed, setCollapsed] = useState(false);
const [internalValue, setInternalValue] = useState(value ?? '');
const handleOnChange = useCallback(
(newValue: string | string[]) => {
if (newValue !== internalValue) {
setInternalValue(newValue);
setTimeout(() => {
onChange(newValue);
});
}
},
[internalValue, onChange],
);
const handleCollapseToggle = useCallback(() => {
setCollapsed(!collapsed);
@ -251,12 +261,12 @@ export default function withFileControl({ forImage = false }: WithImageOptions =
useEffect(() => {
const mediaPath = mediaPaths[controlID];
if (mediaPath && mediaPath !== value) {
onChange(mediaPath);
} else if (mediaPath && mediaPath === value) {
if (mediaPath && mediaPath !== internalValue) {
handleOnChange(mediaPath);
} else if (mediaPath && mediaPath === internalValue) {
removeInsertedMedia(controlID);
}
}, [controlID, field, mediaPaths, onChange, removeInsertedMedia, path, value]);
}, [controlID, handleOnChange, internalValue, mediaPaths, removeInsertedMedia]);
useEffect(() => {
return () => {
@ -291,7 +301,7 @@ export default function withFileControl({ forImage = false }: WithImageOptions =
controlID,
forImage,
privateUpload: field.private,
value: value ?? '',
value: internalValue,
allowMultiple:
'allow_multiple' in mediaLibraryFieldOptions
? mediaLibraryFieldOptions.allow_multiple ?? true
@ -300,7 +310,7 @@ export default function withFileControl({ forImage = false }: WithImageOptions =
field,
});
},
[config, controlID, field, mediaLibraryFieldOptions, openMediaLibrary, value],
[config, controlID, field, mediaLibraryFieldOptions, openMediaLibrary, internalValue],
);
const handleUrl = useCallback(
@ -309,28 +319,29 @@ export default function withFileControl({ forImage = false }: WithImageOptions =
const url = window.prompt(t(`editor.editorWidgets.${subject}.promptUrl`));
return onChange(url);
handleOnChange(url ?? '');
},
[onChange, t],
[handleOnChange, t],
);
const handleRemove = useCallback(
(e: MouseEvent) => {
e.preventDefault();
clearMediaControl(controlID);
return onChange('');
handleOnChange('');
},
[controlID, onChange, clearMediaControl],
[clearMediaControl, controlID, handleOnChange],
);
const onRemoveOne = useCallback(
(index: number) => () => {
if (Array.isArray(value)) {
value.splice(index, 1);
return onChange(sizeOfValue(value) > 0 ? [...value] : null);
if (Array.isArray(internalValue)) {
const newValue = [...internalValue];
newValue.splice(index, 1);
handleOnChange(newValue);
}
},
[onChange, value],
[handleOnChange, internalValue],
);
const onReplaceOne = useCallback(
@ -339,45 +350,86 @@ export default function withFileControl({ forImage = false }: WithImageOptions =
controlID,
forImage,
privateUpload: field.private,
value: value ?? '',
value: internalValue,
replaceIndex: index,
allowMultiple: false,
config,
field,
});
},
[config, controlID, field, openMediaLibrary, value],
[config, controlID, field, openMediaLibrary, internalValue],
);
const onSortEnd = useCallback(
({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => {
if (Array.isArray(value)) {
const newValue = arrayMove(value, oldIndex, newIndex);
return onChange(newValue);
if (Array.isArray(internalValue)) {
const newValue = arrayMoveImmutable(internalValue, oldIndex, newIndex);
handleOnChange(newValue);
}
},
[onChange, value],
[handleOnChange, internalValue],
);
const renderFileLink = useCallback((value: string | undefined | null) => {
const renderFileLink = useCallback((link: string | undefined | null) => {
const size = MAX_DISPLAY_LENGTH;
if (!value || value.length <= size) {
return value;
if (!link || link.length <= size) {
return link;
}
const text = `${value.slice(0, size / 2)}\u2026${value.slice(-(size / 2) + 1)}`;
const text = `${link.slice(0, size / 2)}\u2026${link.slice(-(size / 2) + 1)}`;
return (
<FileLink key={`file-link-${text}`} href={value} rel="noopener" target="_blank">
<FileLink key={`file-link-${text}`} href={link} rel="noopener" target="_blank">
{text}
</FileLink>
);
}, []);
const renderFileLinks = useCallback(() => {
if (isMultiple(value)) {
const [assetSource, setAssetSource] = useState('');
useEffect(() => {
if (Array.isArray(internalValue)) {
return;
}
const newValue = getAsset(internalValue, field)?.toString() ?? '';
if (newValue !== internalValue) {
setAssetSource(newValue);
}
}, [field, getAsset, internalValue]);
const renderedImagesLinks = useMemo(() => {
if (forImage) {
if (!internalValue) {
return null;
}
if (isMultiple(internalValue)) {
return (
<SortableMultiImageWrapper
key="mulitple-image-wrapper"
items={internalValue}
onSortEnd={onSortEnd}
onRemoveOne={onRemoveOne}
onReplaceOne={onReplaceOne}
distance={4}
getAsset={getAsset}
field={field}
axis="xy"
lockToContainerEdges={true}
></SortableMultiImageWrapper>
);
}
return (
<ImageWrapper key="single-image-wrapper">
<Image key="single-image" src={assetSource} />
</ImageWrapper>
);
}
if (isMultiple(internalValue)) {
return (
<FileLinks key="mulitple-file-links">
<FileLinkList key="file-links-list">
{value.map(val => (
{internalValue.map(val => (
<li key={val}>{renderFileLink(val)}</li>
))}
</FileLinkList>
@ -385,43 +437,22 @@ export default function withFileControl({ forImage = false }: WithImageOptions =
);
}
return <FileLinks key="single-file-links">{renderFileLink(value)}</FileLinks>;
}, [renderFileLink, value]);
const renderImages = useCallback(() => {
if (!value) {
return null;
}
if (isMultiple(value)) {
return (
<SortableMultiImageWrapper
key="mulitple-image-wrapper"
items={value}
onSortEnd={onSortEnd}
onRemoveOne={onRemoveOne}
onReplaceOne={onReplaceOne}
distance={4}
getAsset={getAsset}
field={field}
axis="xy"
lockToContainerEdges={true}
></SortableMultiImageWrapper>
);
}
const src = getAsset(value, field)?.toString() ?? '';
return (
<ImageWrapper key="single-image-wrapper">
<Image key="single-image" src={src || ''} />
</ImageWrapper>
);
}, [field, getAsset, onRemoveOne, onReplaceOne, onSortEnd, value]);
return <FileLinks key="single-file-links">{renderFileLink(internalValue)}</FileLinks>;
}, [
assetSource,
field,
getAsset,
internalValue,
onRemoveOne,
onReplaceOne,
onSortEnd,
renderFileLink,
]);
const content = useMemo(() => {
const subject = forImage ? 'image' : 'file';
if (!value) {
if (Array.isArray(internalValue) ? internalValue.length === 0 : isEmpty(internalValue)) {
return (
<StyledButtonWrapper>
<Button color="primary" variant="outlined" key="upload" onClick={handleChange}>
@ -443,7 +474,7 @@ export default function withFileControl({ forImage = false }: WithImageOptions =
return (
<StyledSelection key="selection">
{forImage ? renderImages() : renderFileLinks()}
{renderedImagesLinks}
<StyledButtonWrapper key="controls">
<Button color="primary" variant="outlined" key="add-replace" onClick={handleChange}>
{t(
@ -467,32 +498,34 @@ export default function withFileControl({ forImage = false }: WithImageOptions =
</StyledSelection>
);
}, [
internalValue,
renderedImagesLinks,
handleChange,
t,
allowsMultiple,
chooseUrl,
handleChange,
handleRemove,
handleUrl,
renderFileLinks,
renderImages,
t,
value,
handleRemove,
]);
return (
<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>
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>
),
[collapsed, content, field.label, field.name, handleCollapseToggle, hasErrors, t],
);
};
});
FileControl.displayName = 'FileControl';

View File

@ -1,9 +1,9 @@
import React from 'react';
import { styled } from '@mui/material/styles';
import React, { useEffect, useState } from 'react';
import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer';
import type { FileOrImageField, WidgetPreviewProps, GetAssetFunction } from '../../interface';
import type { FileOrImageField, GetAssetFunction, WidgetPreviewProps } from '../../interface';
interface StyledImageProps {
src: string;
@ -17,14 +17,19 @@ const StyledImage = styled(({ src }: StyledImageProps) => (
height: auto;
`;
interface StyledImageAsset {
interface ImageAssetProps {
getAsset: GetAssetFunction;
value: string;
field: FileOrImageField;
}
function StyledImageAsset({ getAsset, value, field }: StyledImageAsset) {
return <StyledImage src={getAsset(value, field).toString()} />;
function ImageAsset({ getAsset, value, field }: ImageAssetProps) {
const [assetSource, setAssetSource] = useState('');
useEffect(() => {
setAssetSource(getAsset(value, field)?.toString() ?? '');
}, [field, getAsset, value]);
return <StyledImage src={assetSource} />;
}
function ImagePreviewContent({
@ -40,13 +45,13 @@ function ImagePreviewContent({
return (
<>
{value.map(val => (
<StyledImageAsset key={val} value={val} getAsset={getAsset} field={field} />
<ImageAsset key={val} value={val} getAsset={getAsset} field={field} />
))}
</>
);
}
return <StyledImageAsset value={value} getAsset={getAsset} field={field} />;
return <ImageAsset value={value} getAsset={getAsset} field={field} />;
}
function ImagePreview(props: WidgetPreviewProps<string | string[], FileOrImageField>) {

View File

@ -42,9 +42,7 @@ const StyledSortableList = styled(
${
$collapsed
? `
visibility: hidden;
height: 0;
width: 0;
display: none;
`
: `
padding: 16px;

View File

@ -47,9 +47,7 @@ const StyledObjectFieldWrapper = styled(
${
$collapsed
? `
visibility: hidden;
height: 0;
width: 0;
display: none;
`
: ''
}

View File

@ -40,9 +40,7 @@ const StyledMapControlContent = styled(
${
$collapsed
? `
visibility: hidden;
height: 0;
width: 0;
display: none;
`
: ''
}

View File

@ -11,8 +11,8 @@ import { doesUrlFileExist } from '../../lib/util/fetch.util';
import { isNotNullish } from '../../lib/util/null.util';
import { isNotEmpty } from '../../lib/util/string.util';
import useEditorOptions from './hooks/useEditorOptions';
import usePlugins from './hooks/usePlugins';
import useToolbarItems from './hooks/useToolbarItems';
import useWidgetRules from './hooks/useWidgetRules';
import type { RefObject } from 'react';
import type { MarkdownField, MediaLibrary, WidgetControlProps } from '../../interface';
@ -151,38 +151,54 @@ const MarkdownControl = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [field, mediaPath]);
const { initialEditType, height, plugins, ...markdownEditorOptions } = useEditorOptions();
const widgetRules = useWidgetRules(markdownEditorOptions.widgetRules, { getAsset, field });
const { initialEditType, height, ...markdownEditorOptions } = useEditorOptions();
const plugins = usePlugins(markdownEditorOptions.plugins, { getAsset, field, mode: 'editor' });
const toolbarItems = useToolbarItems(markdownEditorOptions.toolbarItems, handleOpenMedialLibrary);
return (
<StyledEditorWrapper key="markdown-control-wrapper">
<FieldLabel
key="markdown-control-label"
isActive={hasFocus}
hasErrors={hasErrors}
onClick={handleLabelClick}
>
{label}
</FieldLabel>
<Editor
key="markdown-control-editor"
initialValue={internalValue}
previewStyle="vertical"
height={height}
initialEditType={initialEditType}
useCommandShortcut={true}
onChange={handleOnChange}
toolbarItems={toolbarItems}
ref={editorRef}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
autofocus={false}
widgetRules={widgetRules}
plugins={plugins}
/>
<Outline key="markdown-control-outline" hasLabel hasError={hasErrors} />
</StyledEditorWrapper>
return useMemo(
() => (
<StyledEditorWrapper key="markdown-control-wrapper">
<FieldLabel
key="markdown-control-label"
isActive={hasFocus}
hasErrors={hasErrors}
onClick={handleLabelClick}
>
{label}
</FieldLabel>
<Editor
key="markdown-control-editor"
initialValue={internalValue}
previewStyle="vertical"
height={height}
initialEditType={initialEditType}
useCommandShortcut={true}
onChange={handleOnChange}
toolbarItems={toolbarItems}
ref={editorRef}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
autofocus={false}
plugins={plugins}
/>
<Outline key="markdown-control-outline" hasLabel hasError={hasErrors} />
</StyledEditorWrapper>
),
[
editorRef,
handleLabelClick,
handleOnBlur,
handleOnChange,
handleOnFocus,
hasErrors,
hasFocus,
height,
initialEditType,
internalValue,
label,
plugins,
toolbarItems,
],
);
};

View File

@ -3,11 +3,14 @@ import React, { useEffect, useRef } from 'react';
import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer';
import useEditorOptions from './hooks/useEditorOptions';
import usePlugins from './hooks/usePlugins';
import type { MarkdownField, WidgetPreviewProps } from '../../interface';
const MarkdownPreview = ({ value }: WidgetPreviewProps<string, MarkdownField>) => {
const { plugins } = useEditorOptions();
const MarkdownPreview = ({ value, getAsset, field }: WidgetPreviewProps<string, MarkdownField>) => {
const options = useEditorOptions();
const plugins = usePlugins(options.plugins, { getAsset, field, mode: 'preview' });
const viewer = useRef<Viewer | null>(null);
useEffect(() => {
@ -20,7 +23,12 @@ const MarkdownPreview = ({ value }: WidgetPreviewProps<string, MarkdownField>) =
return (
<WidgetPreviewContainer>
<Viewer ref={viewer} initialValue={value} plugins={plugins} />
<Viewer
ref={viewer}
initialValue={value}
customHTMLSanitizer={(content: string) => content}
plugins={plugins}
/>
</WidgetPreviewContainer>
);
};

View File

@ -1,35 +0,0 @@
import type { WidgetRulesFactory } from '../../../interface';
const imageFilePattern = /(!)?\[([^\]]*)\]\(([^)]+)\)/;
const defaultWidgetRules: WidgetRulesFactory = ({ getAsset, field }) => [
{
rule: imageFilePattern,
toDOM(text) {
const rule = imageFilePattern;
const matched = text.match(rule);
if (matched) {
if (matched?.length === 4) {
// Image
const img = document.createElement('img');
img.setAttribute('src', getAsset(matched[3] ?? '', field).url);
img.setAttribute('style', 'width: 100%;');
img.innerHTML = matched[2] ?? '';
return img;
} else {
// File
const a = document.createElement('a');
a.setAttribute('target', '_blank');
a.setAttribute('href', matched[2] ?? '');
a.innerHTML = matched[1] ?? '';
return a;
}
}
return document.createElement('div');
},
},
];
export default defaultWidgetRules;

View File

@ -0,0 +1,22 @@
import { useMemo } from 'react';
import imagePlugin from '../plugins/imagePlugin';
import type { MarkdownEditorOptions, MarkdownPluginFactoryProps } from '../../../interface';
const usePlugins = (
editorPlugins: MarkdownEditorOptions['plugins'] = [],
{ getAsset, field, mode }: MarkdownPluginFactoryProps,
) => {
return useMemo(() => {
const plugins = [imagePlugin({ getAsset, field, mode })];
if (plugins) {
plugins.push(...editorPlugins.map(editorPlugin => editorPlugin({ getAsset, field, mode })));
}
return plugins;
}, [editorPlugins, field, getAsset, mode]);
};
export default usePlugins;

View File

@ -1,20 +0,0 @@
import { useMemo } from 'react';
import defaultWidgetRules from '../config/widgetRules';
import type { WidgetRulesFactory, WidgetRulesFactoryProps } from '../../../interface';
const useWidgetRules = (
widgetRules: WidgetRulesFactory | undefined,
{ getAsset, field }: WidgetRulesFactoryProps,
) => {
return useMemo(() => {
const rules = defaultWidgetRules({ getAsset, field });
if (widgetRules) {
rules.push(...widgetRules({ getAsset, field }));
}
return rules;
}, [field, getAsset, widgetRules]);
};
export default useWidgetRules;

View File

@ -0,0 +1,43 @@
import type { CustomHTMLRenderer } from '@toast-ui/editor';
import type { LinkMdNode, MdNode } from '@toast-ui/editor/types/toastmark';
import type { MarkdownPluginFactory, MarkdownPluginFactoryProps } from '../../../interface';
function isLinkNode(node: MdNode): node is LinkMdNode {
return 'destination' in node;
}
const toHTMLRenderers: (props: MarkdownPluginFactoryProps) => CustomHTMLRenderer = ({
getAsset,
field,
}) => ({
image: (node: MdNode, { entering, skipChildren }) => {
if (entering && isLinkNode(node)) {
skipChildren();
return {
type: 'openTag',
tagName: 'img',
outerNewLine: true,
attributes: {
src: node.destination,
onerror: `this.onerror=null; this.src='${
node.destination
? getAsset(node.destination, field)?.toString() ?? node.destination
: ''
}'`,
},
selfClose: true,
};
}
return [];
},
});
const imagePlugin: MarkdownPluginFactory = props => {
return () => ({
toHTMLRenderers: toHTMLRenderers(props),
});
};
export default imagePlugin;

View File

@ -31,9 +31,7 @@ const StyledFieldsBox = styled(
${
$collapsed
? `
visibility: hidden;
height: 0;
width: 0;
display: none;
`
: `
padding: 16px;

View File

@ -5,9 +5,9 @@ import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer';
import type { WidgetPreviewProps, ObjectField, ListField, ObjectValue } from '../../interface';
function ObjectPreview({
value,
field,
}: WidgetPreviewProps<ObjectValue | ObjectValue[], ObjectField | ListField>) {
return <WidgetPreviewContainer>{JSON.stringify(value, null, 2)}</WidgetPreviewContainer>;
return <WidgetPreviewContainer>{field.fields ?? null}</WidgetPreviewContainer>;
}
export default ObjectPreview;

View File

@ -7,17 +7,19 @@ import OutlinedInput from '@mui/material/OutlinedInput';
import Select from '@mui/material/Select';
import React, { useCallback, useMemo, useState } from 'react';
import { isNullish } from '../../lib/util/null.util';
import type { SelectChangeEvent } from '@mui/material/Select';
import type { SelectField, WidgetControlProps } from '../../interface';
interface Option {
label: string;
value: string;
value: string | number;
}
function convertToOption(raw: string | Option | undefined): Option | undefined {
if (typeof raw === 'string') {
return { label: raw, value: raw };
function convertToOption(raw: string | number | Option | undefined): Option | undefined {
if (typeof raw === 'string' || typeof raw === 'number') {
return { label: `${raw}`, value: raw };
}
return raw;
@ -29,15 +31,43 @@ const SelectControl = ({
value,
hasErrors,
onChange,
}: WidgetControlProps<string | string[], SelectField>) => {
}: WidgetControlProps<string | number | (string | number)[], SelectField>) => {
const [internalValue, setInternalValue] = useState(value);
const fieldOptions: (string | Option)[] = useMemo(() => field.options, [field.options]);
const isMultiple = useMemo(() => field.multiple ?? false, [field.multiple]);
const options = useMemo(
() => fieldOptions.map(convertToOption).filter(Boolean) as Option[],
[fieldOptions],
);
const optionsByValue = useMemo(
() =>
options.reduce((acc, option) => {
acc[`${option.value}`] = option;
return acc;
}, {} as Record<string, Option>),
[options],
);
const stringValueOptions = useMemo(
() =>
options.map(option => ({
label: option.label,
value: `${option.value}`,
})),
[options],
);
const handleChange = useCallback(
(event: SelectChangeEvent<string | string[]>) => {
const selectedOption = event.target.value;
const selectedValue = event.target.value;
const isMultiple = field.multiple ?? false;
const isEmpty =
isMultiple && Array.isArray(selectedOption) ? !selectedOption?.length : !selectedOption;
isMultiple && Array.isArray(selectedValue)
? !selectedValue?.length
: isNullish(selectedValue);
if (field.required && isEmpty && isMultiple) {
setInternalValue([]);
@ -45,53 +75,63 @@ const SelectControl = ({
} else if (isEmpty) {
setInternalValue('');
onChange('');
} else if (typeof selectedOption === 'string' || isMultiple) {
setInternalValue(selectedOption);
onChange(selectedOption);
} else if (typeof selectedValue === 'string') {
const selectedOption = optionsByValue[selectedValue];
const optionValue = selectedOption?.value ?? '';
setInternalValue(optionValue);
onChange(optionValue);
} else if (isMultiple) {
const optionValues = selectedValue.map(v => {
const selectedOption = optionsByValue[v];
return selectedOption?.value ?? '';
});
setInternalValue(optionValues);
onChange(optionValues);
}
},
[field, onChange],
[field.multiple, field.required, onChange, optionsByValue],
);
const fieldOptions: (string | Option)[] = field.options;
const isMultiple = field.multiple ?? false;
const stringValue = useMemo(() => {
if (!internalValue) {
return isMultiple ? [] : '';
}
const options = useMemo(
() => [...(fieldOptions.map(convertToOption) as Option[])].filter(Boolean),
[fieldOptions],
);
if (Array.isArray(internalValue)) {
return internalValue.map(v => `${v}`);
}
const optionsByValue = options.reduce((acc, option) => {
acc[option.value] = option;
return acc;
}, {} as Record<string, Option>);
return `${internalValue}`;
}, [isMultiple, internalValue]);
return (
<FormControl fullWidth error={hasErrors}>
<InputLabel id="demo-simple-select-label">{label}</InputLabel>
<Select
value={(internalValue ? internalValue : isMultiple ? [] : '') as string | string[]}
value={stringValue}
onChange={handleChange}
multiple={isMultiple}
label={label}
input={isMultiple ? <OutlinedInput id="select-multiple-chip" label={label} /> : undefined}
renderValue={selectValues =>
Array.isArray(selectValues) ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selectValues.map(selectValue => {
const label = optionsByValue[selectValue]?.label ?? selectValue;
return <Chip key={selectValue} label={label} />;
})}
</Box>
) : (
selectValues
)
}
renderValue={selectValues => {
if (Array.isArray(selectValues)) {
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selectValues.map(selectValue => {
const label = optionsByValue[selectValue]?.label ?? selectValue;
return <Chip key={selectValue} label={label} />;
})}
</Box>
);
}
return optionsByValue[selectValues]?.label ?? selectValues;
}}
>
<MenuItem key={`empty-option`} value="">
&nbsp;
</MenuItem>
{options.map(option => (
{stringValueOptions.map(option => (
<MenuItem key={`option-${option.value}`} value={option.value}>
{option.label}
</MenuItem>

View File

@ -5,27 +5,33 @@ import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer';
import type { SelectField, WidgetPreviewProps } from '../../interface';
interface ListPreviewProps {
values: string[];
values: (string | number)[];
}
const ListPreview = ({ values }: ListPreviewProps) => {
return (
<ul>
{(values as string[]).map((value, idx) => (
{values.map((value, idx) => (
<li key={idx}>{value}</li>
))}
</ul>
);
};
const SelectPreview = ({ value }: WidgetPreviewProps<string | string[], SelectField>) => {
const SelectPreview = ({
value,
}: WidgetPreviewProps<string | number | (string | number)[], SelectField>) => {
if (!value) {
return <WidgetPreviewContainer />;
}
return (
<WidgetPreviewContainer>
{typeof value === 'string' ? value : <ListPreview values={value} />}
{typeof value === 'string' || typeof value === 'number' ? (
value
) : (
<ListPreview values={value} />
)}
</WidgetPreviewContainer>
);
};

View File

@ -5,7 +5,7 @@ import { validateMinMax } from '../../lib/widgets/validations';
import type { SelectField, WidgetParam } from '../../interface';
const SelectWidget = (): WidgetParam<string | string[], SelectField> => {
const SelectWidget = (): WidgetParam<string | number | (string | number)[], SelectField> => {
return {
name: 'select',
controlComponent,