From 8c8a59093db50e110a77ded49b8e24fec0599e3b Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Thu, 27 Oct 2022 12:24:30 -0400 Subject: [PATCH] Bugfixes - Editor images, git-gateway auth, editor scrolling, numeric selects, object/list previews (#50) * v1.0.0-alpha26 --- .gitignore | 24 -- core/.gitignore | 56 +++ core/dev-test/config.yml | 12 +- core/package.json | 7 +- core/src/actions/auth.ts | 7 +- core/src/actions/config.ts | 4 +- core/src/actions/media.ts | 9 +- .../git-gateway/AuthenticationPage.tsx | 176 ++------ .../backends/git-gateway/implementation.tsx | 54 +-- core/src/backends/test/implementation.ts | 2 +- core/src/bootstrap.tsx | 6 +- .../EditorControlPane/EditorControl.tsx | 25 +- .../EditorControlPane/EditorControlPane.tsx | 4 +- .../EditorPreviewContent.tsx | 6 +- .../EditorPreviewPane/EditorPreviewPane.tsx | 127 ++++-- .../Editor/EditorPreviewPane/PreviewHOC.tsx | 2 +- core/src/components/Editor/EditorToolbar.tsx | 4 +- core/src/extensions.ts | 6 +- core/src/interface.ts | 36 +- core/src/lib/registry.ts | 6 +- core/src/lib/util/collection.util.ts | 22 + core/src/lib/util/field.util.ts | 35 ++ core/src/lib/widgets/validations.ts | 14 +- core/src/locales/en/index.ts | 1 + core/src/valueObjects/AssetProxy.ts | 2 +- core/src/widgets/code/CodeControl.tsx | 4 +- core/src/widgets/colorstring/ColorControl.tsx | 4 +- core/src/widgets/file/FilePreview.tsx | 36 +- core/src/widgets/file/withFileControl.tsx | 253 +++++++----- core/src/widgets/image/ImagePreview.tsx | 19 +- core/src/widgets/list/ListControl.tsx | 4 +- core/src/widgets/list/ListItem.tsx | 4 +- core/src/widgets/map/withMapControl.tsx | 4 +- core/src/widgets/markdown/MarkdownControl.tsx | 78 ++-- core/src/widgets/markdown/MarkdownPreview.tsx | 14 +- .../widgets/markdown/config/widgetRules.ts | 35 -- core/src/widgets/markdown/hooks/usePlugins.ts | 22 + .../widgets/markdown/hooks/useWidgetRules.ts | 20 - .../widgets/markdown/plugins/imagePlugin.ts | 43 ++ core/src/widgets/object/ObjectControl.tsx | 4 +- core/src/widgets/object/ObjectPreview.tsx | 4 +- core/src/widgets/select/SelectControl.tsx | 110 +++-- core/src/widgets/select/SelectPreview.tsx | 14 +- core/src/widgets/select/index.ts | 2 +- core/yarn.lock | 383 +----------------- website/.gitignore | 20 + website/content/docs/releases.mdx | 12 +- 47 files changed, 794 insertions(+), 942 deletions(-) delete mode 100644 .gitignore create mode 100644 core/.gitignore delete mode 100644 core/src/widgets/markdown/config/widgetRules.ts create mode 100644 core/src/widgets/markdown/hooks/usePlugins.ts delete mode 100644 core/src/widgets/markdown/hooks/useWidgetRules.ts create mode 100644 core/src/widgets/markdown/plugins/imagePlugin.ts diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 188492b9..00000000 --- a/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -dist/ -bin/ -node_modules/ -npm-debug.log -.DS_Store -.tern-project -yarn-error.log -.vscode/ -.idea/ -manifest.yml -.imdone/ -website/data/contributors.json -cypress/videos -cypress/screenshots -__diff_output__ -coverage/ -.cache -*.log -.env -.temp/ -*.tgz -old-website -website/public/sw.js -website/public/workbox*.js diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 00000000..2f87433c --- /dev/null +++ b/core/.gitignore @@ -0,0 +1,56 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +dist/ +bin/ +.tern-project +.vscode/ +.idea/ +manifest.yml +.imdone/ +data/contributors.json +cypress/videos +cypress/screenshots +__diff_output__ +.cache +*.log +.env +.temp/ +*.tgz +public/sw.js +public/workbox*.js + diff --git a/core/dev-test/config.yml b/core/dev-test/config.yml index 9a387339..19eeebe1 100644 --- a/core/dev-test/config.yml +++ b/core/dev-test/config.yml @@ -90,8 +90,6 @@ collections: - name: widgets label: Widgets delete: false - editor: - preview: false files: - name: boolean label: Boolean @@ -620,6 +618,16 @@ collections: value: 2 - label: Three value: 3 + - label: Select mixed string and numeric + name: select_mixed_string_numeric + widget: select + options: + - label: One + value: "One" + - label: Two + value: 2 + - label: Three + value: 3 - label: Hidden name: hidden widget: hidden diff --git a/core/package.json b/core/package.json index d0e17731..e0aa548d 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@staticcms/core", - "version": "1.0.0-alpha16", + "version": "1.0.0-alpha26", "license": "MIT", "description": "Static CMS core application.", "repository": "https://github.com/StaticJsCMS/static-cms", @@ -20,7 +20,6 @@ "format:prettier": "prettier \"{{src,scripts,website}/**/,}*.{js,jsx,ts,tsx,css}\"", "format": "run-s \"lint:js --fix --quiet\" \"format:prettier --write\"", "lint-quiet": "run-p -c --aggregate-output \"lint:* --quiet\"", - "lint:css": "stylelint --ignore-path .gitignore \"{src/**/*.{css,js,jsx,ts,tsx},website/**/*.css}\"", "lint:format": "prettier \"src/**/*.{js,jsx,ts,tsx,css}\" --list-different", "lint:js": "eslint --color --ignore-path .gitignore \"src/**/*.{js,jsx,ts,tsx}\"", "lint": "run-p -c --aggregate-output \"lint:*\"", @@ -216,10 +215,6 @@ "simple-git": "3.14.1", "source-map-loader": "4.0.0", "style-loader": "3.3.1", - "stylelint": "14.12.1", - "stylelint-config-standard-scss": "3.0.0", - "stylelint-config-styled-components": "0.1.1", - "stylelint-processor-styled-components": "1.10.0", "to-string-loader": "1.2.0", "typescript": "4.8.4", "webpack": "5.74.0", diff --git a/core/src/actions/auth.ts b/core/src/actions/auth.ts index f05e0d69..1d003d3b 100644 --- a/core/src/actions/auth.ts +++ b/core/src/actions/auth.ts @@ -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()); }); }; diff --git a/core/src/actions/config.ts b/core/src/actions/config.ts index 2d543abe..1ac8a244 100644 --- a/core/src/actions/config.ts +++ b/core/src/actions/config.ts @@ -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); diff --git a/core/src/actions/media.ts b/core/src/actions/media.ts index 5f630dd5..aea3dccc 100644 --- a/core/src/actions/media.ts +++ b/core/src/actions/media.ts @@ -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, getState: () => RootState) => { - if (!path) { + if (!collection || !entry || !path) { return emptyAsset; } diff --git a/core/src/backends/git-gateway/AuthenticationPage.tsx b/core/src/backends/git-gateway/AuthenticationPage.tsx index edbe8433..0e4e4da2 100644 --- a/core/src/backends/git-gateway/AuthenticationPage.tsx +++ b/core/src/backends/git-gateway/AuthenticationPage.tsx @@ -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) => { - setEmail(event.target.value); - }, []); + const pageContent = useMemo(() => { + if (!window.netlifyIdentity) { + return t('auth.errors.netlifyIdentityNotFound'); + } - const handlePasswordChange = useCallback((event: ChangeEvent) => { - setPassword(event.target.value); - }, []); - - const handleLogin = useCallback( - async (e: FormEvent) => { - 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 ( - - {errors.identity} - - } - t={t} - /> - ); - } else { - return ( - + + {errors.identity} + ); } - } + + return null; + }, [errors.identity, t]); return ( - {!errors.server ? null : {String(errors.server)}} - - - - - } + onLogin={handleIdentity} + buttonContent={t('auth.loginWithNetlifyIdentity')} + pageContent={pageContent} + loginDisabled={loggingIn} t={t} /> ); diff --git a/core/src/backends/git-gateway/implementation.tsx b/core/src/backends/git-gateway/implementation.tsx index d4790492..ac9f3309 100644 --- a/core/src/backends/git-gateway/implementation.tsx +++ b/core/src/backends/git-gateway/implementation.tsx @@ -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) => diff --git a/core/src/backends/test/implementation.ts b/core/src/backends/test/implementation.ts index 87f6fa0e..df8648d3 100644 --- a/core/src/backends/test/implementation.ts +++ b/core/src/backends/test/implementation.ts @@ -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); diff --git a/core/src/bootstrap.tsx b/core/src/bootstrap.tsx index 952ceb2b..416bb040 100644 --- a/core/src/bootstrap.tsx +++ b/core/src/bootstrap.tsx @@ -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, ); diff --git a/core/src/components/Editor/EditorControlPane/EditorControl.tsx b/core/src/components/Editor/EditorControlPane/EditorControl.tsx index 68cb9f59..ec555f35 100644 --- a/core/src/components/Editor/EditorControlPane/EditorControl.tsx +++ b/core/src/components/Editor/EditorControlPane/EditorControl.tsx @@ -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 = ({ <> {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 && {fieldHint}} + {fieldHint ? ( + + {fieldHint} + + ) : null} {hasErrors ? ( - + {errors.map(error => { return ( error.message && diff --git a/core/src/components/Editor/EditorControlPane/EditorControlPane.tsx b/core/src/components/Editor/EditorControlPane/EditorControlPane.tsx index c97c1e78..1c65eb2d 100644 --- a/core/src/components/Editor/EditorControlPane/EditorControlPane.tsx +++ b/core/src/components/Editor/EditorControlPane/EditorControlPane.tsx @@ -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 ( { +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; diff --git a/core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx b/core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx index 461950b0..daee58f3 100644 --- a/core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx +++ b/core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx @@ -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 & { - 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( ); } - 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, + 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) => { 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 = {}; - 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) => { if (Array.isArray(value)) { return value.map(val => { const widgets = nestedFields.reduce((acc, field, index) => { - acc[field.name] =
{getWidget(field, val, entry, handleGetAsset)}
; + acc[field.name] =
{widgetFor(field.name)}
; return acc; }, {} as Record); return { data: val, widgets }; @@ -315,29 +367,24 @@ const PreviewPane = (props: TranslatedProps) => { return { data: value, widgets: nestedFields.reduce((acc, field, index) => { - acc[field.name] =
{getWidget(field, value, entry, handleGetAsset)}
; + acc[field.name] =
{widgetFor(field.name)}
; return acc; }, {} as Record), }; }, - [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 ; } return ; }), + , + ], [], ); diff --git a/core/src/components/Editor/EditorPreviewPane/PreviewHOC.tsx b/core/src/components/Editor/EditorPreviewPane/PreviewHOC.tsx index 7f7803c1..6a09eb18 100644 --- a/core/src/components/Editor/EditorPreviewPane/PreviewHOC.tsx +++ b/core/src/components/Editor/EditorPreviewPane/PreviewHOC.tsx @@ -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 { // eslint-disable-next-line @typescript-eslint/no-explicit-any previewComponent: WidgetPreviewComponent; } diff --git a/core/src/components/Editor/EditorToolbar.tsx b/core/src/components/Editor/EditorToolbar.tsx index 9ca16667..688bd9b8 100644 --- a/core/src/components/Editor/EditorToolbar.tsx +++ b/core/src/components/Editor/EditorToolbar.tsx @@ -172,7 +172,9 @@ const EditorToolbar = ({ if (canCreate) { items.push( - {t('editor.editorToolbar.duplicate')}, + + {t('editor.editorToolbar.duplicate')} + , ); } diff --git a/core/src/extensions.ts b/core/src/extensions.ts index f2f97806..8ce3c12a 100644 --- a/core/src/extensions.ts +++ b/core/src/extensions.ts @@ -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, diff --git a/core/src/interface.ts b/core/src/interface.ts index d402279b..15542c53 100644 --- a/core/src/interface.ts +++ b/core/src/interface.ts @@ -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 { } export interface WidgetPreviewProps { + collection: Collection; entry: Entry; - field: F; + field: RenderedField; getAsset: GetAssetFunction; resolveWidget: (name: string) => Widget; value: T | undefined | null; @@ -525,6 +529,10 @@ export type AuthScope = 'repo' | 'public_repo'; export type SlugEncoding = 'unicode' | 'ascii'; +export type RenderedField = Omit & { + 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[]; } diff --git a/core/src/lib/registry.ts b/core/src/lib/registry.ts index f1a0af29..73135e5c 100644 --- a/core/src/lib/registry.ts +++ b/core/src/lib/registry.ts @@ -133,7 +133,11 @@ export function registerWidget( name: string | WidgetParam | WidgetParam[], control?: string | Widget['control'], preview?: Widget['preview'], - { schema, validator, getValidValue }: WidgetOptions = {}, + { + schema, + validator = () => false, + getValidValue = (value: unknown) => value, + }: WidgetOptions = {}, ): void { if (Array.isArray(name)) { name.forEach(widget => { diff --git a/core/src/lib/util/collection.util.ts b/core/src/lib/util/collection.util.ts index 3e127e69..ab3486a5 100644 --- a/core/src/lib/util/collection.util.ts +++ b/core/src/lib/util/collection.util.ts @@ -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 = {}; + if (titleField) { + iFields[titleField] = INFERABLE_FIELDS.title; + } + if (shortTitleField) { + iFields[shortTitleField] = INFERABLE_FIELDS.shortTitle; + } + if (authorField) { + iFields[authorField] = INFERABLE_FIELDS.author; + } + return iFields; + }, [collection]); +} diff --git a/core/src/lib/util/field.util.ts b/core/src/lib/util/field.util.ts index de9fbb60..de97bf73 100644 --- a/core/src/lib/util/field.util.ts +++ b/core/src/lib/util/field.util.ts @@ -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('.'), + ); +} diff --git a/core/src/lib/widgets/validations.ts b/core/src/lib/widgets/validations.ts index f89c4b6d..ccfc6e98 100644 --- a/core/src/lib/widgets/validations.ts +++ b/core/src/lib/widgets/validations.ts @@ -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'); } } diff --git a/core/src/locales/en/index.ts b/core/src/locales/en/index.ts index 7e6a5590..01115c39 100644 --- a/core/src/locales/en/index.ts +++ b/core/src/locales/en/index.ts @@ -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.', }, diff --git a/core/src/valueObjects/AssetProxy.ts b/core/src/valueObjects/AssetProxy.ts index 89f1af90..1fb23675 100644 --- a/core/src/valueObjects/AssetProxy.ts +++ b/core/src/valueObjects/AssetProxy.ts @@ -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; diff --git a/core/src/widgets/code/CodeControl.tsx b/core/src/widgets/code/CodeControl.tsx index d69b5974..47aa47a0 100644 --- a/core/src/widgets/code/CodeControl.tsx +++ b/core/src/widgets/code/CodeControl.tsx @@ -39,9 +39,7 @@ const StyledCodeControlContent = styled( ${ $collapsed ? ` - visibility: hidden; - height: 0; - width: 0; + display: none; ` : '' } diff --git a/core/src/widgets/colorstring/ColorControl.tsx b/core/src/widgets/colorstring/ColorControl.tsx index 9db5528d..9d8d2f68 100644 --- a/core/src/widgets/colorstring/ColorControl.tsx +++ b/core/src/widgets/colorstring/ColorControl.tsx @@ -36,9 +36,7 @@ const StyledColorControlContent = styled( ${ $collapsed ? ` - visibility: hidden; - height: 0; - width: 0; + display: none; ` : ` padding: 16px; diff --git a/core/src/widgets/file/FilePreview.tsx b/core/src/widgets/file/FilePreview.tsx index 99463095..95f3cfa5 100644 --- a/core/src/widgets/file/FilePreview.tsx +++ b/core/src/widgets/file/FilePreview.tsx @@ -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) => ( - - {path} - -))` +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 ( + + {value} + + ); +}; + +const StyledFileLink = styled(FileLink)` display: block; `; @@ -28,7 +42,7 @@ function FileLinkList({ values, getAsset, field }: FileLinkListProps) { return (
{values.map(value => ( - + ))}
); @@ -47,7 +61,7 @@ function FileContent({ return ; } - return ; + return ; } function FilePreview(props: WidgetPreviewProps) { diff --git a/core/src/widgets/file/withFileControl.tsx b/core/src/widgets/file/withFileControl.tsx index c1a0c537..0b6af5a2 100644 --- a/core/src/widgets/file/withFileControl.tsx +++ b/core/src/widgets/file/withFileControl.tsx @@ -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( ({ itemValue, getAsset, field, onRemove, onReplace }: SortableImageProps) => { + const [assetSource, setAssetSource] = useState(''); + useEffect(() => { + setAssetSource(getAsset(itemValue, field)?.toString() ?? ''); + }, [field, getAsset, itemValue]); + return (
- + ) => { + const FileControl = memo((props: WidgetControlProps) => { + 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 ( - + {text} ); }, []); - 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 ( + + ); + } + + return ( + + + + ); + } + + if (isMultiple(internalValue)) { return ( - {value.map(val => ( + {internalValue.map(val => (
  • {renderFileLink(val)}
  • ))}
    @@ -385,43 +437,22 @@ export default function withFileControl({ forImage = false }: WithImageOptions = ); } - return {renderFileLink(value)}; - }, [renderFileLink, value]); - - const renderImages = useCallback(() => { - if (!value) { - return null; - } - - if (isMultiple(value)) { - return ( - - ); - } - - const src = getAsset(value, field)?.toString() ?? ''; - return ( - - - - ); - }, [field, getAsset, onRemoveOne, onReplaceOne, onSortEnd, value]); + return {renderFileLink(internalValue)}; + }, [ + 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 (