Bugfixes - Editor images, git-gateway auth, editor scrolling, numeric selects, object/list previews (#50)
* v1.0.0-alpha26
This commit is contained in:
committed by
GitHub
parent
1de3d52d57
commit
8c8a59093d
@ -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());
|
||||
});
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
@ -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) =>
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
);
|
||||
|
||||
|
@ -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 &&
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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>,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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[];
|
||||
}
|
||||
|
@ -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 => {
|
||||
|
@ -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]);
|
||||
}
|
||||
|
@ -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('.'),
|
||||
);
|
||||
}
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
@ -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.',
|
||||
},
|
||||
|
@ -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;
|
||||
|
@ -39,9 +39,7 @@ const StyledCodeControlContent = styled(
|
||||
${
|
||||
$collapsed
|
||||
? `
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
width: 0;
|
||||
display: none;
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
@ -36,9 +36,7 @@ const StyledColorControlContent = styled(
|
||||
${
|
||||
$collapsed
|
||||
? `
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
width: 0;
|
||||
display: none;
|
||||
`
|
||||
: `
|
||||
padding: 16px;
|
||||
|
@ -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>) {
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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>) {
|
||||
|
@ -42,9 +42,7 @@ const StyledSortableList = styled(
|
||||
${
|
||||
$collapsed
|
||||
? `
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
width: 0;
|
||||
display: none;
|
||||
`
|
||||
: `
|
||||
padding: 16px;
|
||||
|
@ -47,9 +47,7 @@ const StyledObjectFieldWrapper = styled(
|
||||
${
|
||||
$collapsed
|
||||
? `
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
width: 0;
|
||||
display: none;
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
@ -40,9 +40,7 @@ const StyledMapControlContent = styled(
|
||||
${
|
||||
$collapsed
|
||||
? `
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
width: 0;
|
||||
display: none;
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
22
core/src/widgets/markdown/hooks/usePlugins.ts
Normal file
22
core/src/widgets/markdown/hooks/usePlugins.ts
Normal 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;
|
@ -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;
|
43
core/src/widgets/markdown/plugins/imagePlugin.ts
Normal file
43
core/src/widgets/markdown/plugins/imagePlugin.ts
Normal 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;
|
@ -31,9 +31,7 @@ const StyledFieldsBox = styled(
|
||||
${
|
||||
$collapsed
|
||||
? `
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
width: 0;
|
||||
display: none;
|
||||
`
|
||||
: `
|
||||
padding: 16px;
|
||||
|
@ -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;
|
||||
|
@ -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="">
|
||||
|
||||
</MenuItem>
|
||||
{options.map(option => (
|
||||
{stringValueOptions.map(option => (
|
||||
<MenuItem key={`option-${option.value}`} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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,
|
||||
|
Reference in New Issue
Block a user