Bugfixes (#61)

- Fix react key issue with typed list preview children
- Added error outline to relation widget
- Added typing support to template previews
- Discard draft entry from store when navigating away from editor (This fixes an issue with the media library thinking it was still in the editor, causing it to upload media as draft)
- Properly type file formatters
- Fix local backup not working
This commit is contained in:
Daniel Lautzenheiser 2022-11-02 08:54:30 -04:00 committed by GitHub
parent 763f33ef5e
commit 766009aa0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 120 additions and 109 deletions

View File

@ -1,10 +1,10 @@
backend:
name: github
branch: master
repo: owner/repo
branch: main
repo: staticjscms/static-cms-github
media_folder: static/media
public_folder: /media
media_folder: assets/upload
public_folder: /assets/upload
collections:
- name: posts
label: Posts

View File

@ -102,7 +102,6 @@
"react-router-dom": "6.4.1",
"react-scroll-sync": "0.9.0",
"react-sortable-hoc": "2.0.0",
"react-split-pane": "0.1.92",
"react-textarea-autosize": "8.3.4",
"react-topbar-progress-indicator": "4.1.1",
"react-virtualized-auto-sizer": "1.0.7",

View File

@ -767,7 +767,8 @@ export class Backend {
}
return Object.assign(entry, { data: isError(data) ? {} : data });
}
return format.fromFile(entry);
return entry;
};
}
@ -967,7 +968,7 @@ export class Backend {
const format = resolveFormat(collection, entry);
const fieldsOrder = this.fieldsOrder(collection, entry);
const fieldsComments = selectFieldsComments(collection, entry);
return format && format.toFile(entry.data, fieldsOrder, fieldsComments);
return format ? format.toFile(entry.data ?? {}, fieldsOrder, fieldsComments) : '';
}
fieldsOrder(collection: Collection, entry: Entry) {

View File

@ -4,11 +4,12 @@ import { styled } from '@mui/material/styles';
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { ScrollSync } from 'react-scroll-sync';
import TopBarProgress from 'react-topbar-progress-indicator';
import { loginUser as loginUserAction } from '../../actions/auth';
import { discardDraft as discardDraftAction } from '../../actions/entries';
import { currentBackend } from '../../backend';
import { colors, GlobalStyles } from '../../components/UI/styles';
import { history } from '../../routing/history';
@ -26,6 +27,7 @@ import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
import type { Collections, Credentials, TranslatedProps } from '../../interface';
import type { RootState } from '../../store';
TopBarProgress.config({
barColors: {
0: colors.active,
@ -91,6 +93,7 @@ const App = ({
useMediaLibrary,
t,
scrollSyncEnabled,
discardDraft,
}: TranslatedProps<AppProps>) => {
const configError = useCallback(
(error?: string) => {
@ -157,6 +160,14 @@ const App = ({
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
const { pathname } = useLocation();
React.useEffect(() => {
if (!/\/collections\/[a-zA-Z0-9_-]+\/entries\/[a-zA-Z0-9_-]+/g.test(pathname)) {
discardDraft();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
const content = useMemo(() => {
if (!user) {
return authenticationPage;
@ -264,6 +275,7 @@ function mapStateToProps(state: RootState) {
const mapDispatchToProps = {
loginUser: loginUserAction,
discardDraft: discardDraftAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);

View File

@ -75,7 +75,8 @@ const Editor = ({
debounce(function (entry: Entry, collection: Collection) {
persistLocalBackup(entry, collection);
}, 2000),
[persistLocalBackup],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const deleteBackup = useCallback(() => {
@ -84,7 +85,8 @@ const Editor = ({
deleteLocalBackup(collection, slug);
}
deleteDraftLocalBackup();
}, [collection, createBackup, deleteDraftLocalBackup, deleteLocalBackup, slug]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collection, createBackup, slug]);
const [submitted, setSubmitted] = useState(false);
const handlePersistEntry = useCallback(
@ -195,14 +197,12 @@ const Editor = ({
useEffect(() => {
if (hasChanged && entryDraft.entry) {
createBackup(entryDraft.entry, collection);
} else if (localBackup) {
deleteBackup();
}
return () => {
createBackup.flush();
};
}, [collection, createBackup, deleteBackup, entryDraft.entry, hasChanged, localBackup]);
}, [collection, createBackup, entryDraft.entry, hasChanged]);
const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
const [preSlug, setPrevSlug] = useState<string | undefined | null>(null);
@ -280,11 +280,6 @@ const Editor = ({
};
}, [collection.name, deleteBackup, discardDraft, navigationBlocker]);
// TODO Is this needed?
// if (!collectionEntriesLoaded) {
// loadEntries(collection);
// }
if (entry && entry.error) {
return (
<div>

View File

@ -117,7 +117,7 @@ interface ControlHintProps {
$error: boolean;
}
export const ControlHint = styled(
const ControlHint = styled(
'p',
transientOptions,
)<ControlHintProps>(

View File

@ -94,6 +94,7 @@ function getWidgetFor(
getAsset: GetAssetFunction,
widgetFields: Field[] = fields,
values: EntryData = entry.data,
idx: number | null = null,
): ReactNode {
// We retrieve the field by name so that this function can also be used in
// custom preview templates, where the field object can't be passed in.
@ -157,7 +158,7 @@ function getWidgetFor(
value.toString().length < 50
) {
renderedValue = (
<div>
<div key={field.name}>
<>
<strong>{field.label ?? field.name}:</strong> {value}
</>
@ -165,7 +166,7 @@ function getWidgetFor(
);
}
return renderedValue
? getWidget(config, fieldWithWidgets, collection, renderedValue, entry, getAsset)
? getWidget(config, fieldWithWidgets, collection, renderedValue, entry, getAsset, idx)
: null;
}
@ -237,6 +238,7 @@ function widgetsForNestedFields(
getAsset: GetAssetFunction,
widgetFields: Field[],
values: EntryData,
idx: number | null = null,
) {
return widgetFields
.map(field =>
@ -250,6 +252,7 @@ function widgetsForNestedFields(
getAsset,
widgetFields,
values,
idx,
),
)
.filter(widget => Boolean(widget)) as JSX.Element[];
@ -283,6 +286,7 @@ function getTypedNestedWidgets(
getAsset,
itemType.fields,
value,
index,
);
})
.filter(Boolean);

View File

@ -0,0 +1,8 @@
export abstract class FileFormatter {
abstract fromFile(content: string): object;
abstract toFile(
data: object,
sortedKeys?: string[],
comments?: Record<string, string>,
): string;
}

View File

@ -0,0 +1,13 @@
import { FileFormatter } from './FileFormatter';
class JsonFormatter extends FileFormatter {
fromFile(content: string) {
return JSON.parse(content);
}
toFile(data: object) {
return JSON.stringify(data, null, 2);
}
}
export default new JsonFormatter();

View File

@ -1,9 +1,10 @@
import toml from '@iarna/toml';
import tomlify from 'tomlify-j0.4';
import moment from 'moment';
import tomlify from 'tomlify-j0.4';
import AssetProxy from '../valueObjects/AssetProxy';
import { sortKeys } from './helpers';
import { FileFormatter } from './FileFormatter';
function outputReplacer(_key: string, value: unknown) {
if (moment.isMoment(value)) {
@ -22,12 +23,14 @@ function outputReplacer(_key: string, value: unknown) {
return false;
}
export default {
class TomlFormatter extends FileFormatter {
fromFile(content: string) {
return toml.parse(content);
},
}
toFile(data: object, sortedKeys: string[] = []) {
return tomlify.toToml(data, { replace: outputReplacer, sort: sortKeys(sortedKeys) });
},
};
toFile(data: object, sortedKeys: string[] = []): string {
return tomlify.toToml(data as object, { replace: outputReplacer, sort: sortKeys(sortedKeys) });
}
}
export default new TomlFormatter();

View File

@ -1,6 +1,7 @@
import yaml from 'yaml';
import { sortKeys } from './helpers';
import { FileFormatter } from './FileFormatter';
import type { Pair, YAMLMap, YAMLSeq } from 'yaml/types';
@ -20,13 +21,13 @@ function addComments(items: Array<Pair>, comments: Record<string, string>, prefi
});
}
export default {
class YamlFormatter extends FileFormatter {
fromFile(content: string) {
if (content && content.trim().endsWith('---')) {
content = content.trim().slice(0, -3);
}
return yaml.parse(content);
},
}
toFile(data: object, sortedKeys: string[] = [], comments: Record<string, string> = {}) {
const contents = yaml.createNode(data) as YAMLMap | YAMLSeq;
@ -38,5 +39,7 @@ export default {
doc.contents = contents;
return doc.toString();
},
};
}
}
export default new YamlFormatter();

View File

@ -1,12 +1,11 @@
import get from 'lodash/get';
import yamlFormatter from './yaml';
import tomlFormatter from './toml';
import jsonFormatter from './json';
import YamlFormatter from './YamlFormatter';
import TomlFormatter from './TomlFormatter';
import JsonFormatter from './JsonFormatter';
import { FrontmatterInfer, frontmatterJSON, frontmatterTOML, frontmatterYAML } from './frontmatter';
import type { Delimiter } from './frontmatter';
import type { Collection, Entry, Format } from '../interface';
import type { FileFormatter } from './FileFormatter';
export const frontmatterFormats = ['yaml-frontmatter', 'toml-frontmatter', 'json-frontmatter'];
@ -21,30 +20,32 @@ export const formatExtensions = {
'yaml-frontmatter': 'md',
};
export const extensionFormatters = {
yml: yamlFormatter,
yaml: yamlFormatter,
toml: tomlFormatter,
json: jsonFormatter,
export const extensionFormatters: Record<string, FileFormatter> = {
yml: YamlFormatter,
yaml: YamlFormatter,
toml: TomlFormatter,
json: JsonFormatter,
md: FrontmatterInfer,
markdown: FrontmatterInfer,
html: FrontmatterInfer,
};
function formatByName(name: Format, customDelimiter?: Delimiter) {
return {
yml: yamlFormatter,
yaml: yamlFormatter,
toml: tomlFormatter,
json: jsonFormatter,
function formatByName(name: Format, customDelimiter?: Delimiter): FileFormatter {
const fileFormatter: Record<string, FileFormatter> = {
yml: YamlFormatter,
yaml: YamlFormatter,
toml: TomlFormatter,
json: JsonFormatter,
frontmatter: FrontmatterInfer,
'json-frontmatter': frontmatterJSON(customDelimiter),
'toml-frontmatter': frontmatterTOML(customDelimiter),
'yaml-frontmatter': frontmatterYAML(customDelimiter),
}[name];
};
return fileFormatter[name];
}
export function resolveFormat(collection: Collection, entry: Entry) {
export function resolveFormat(collection: Collection, entry: Entry): FileFormatter | undefined {
// Check for custom delimiter
const frontmatter_delimiter = collection.frontmatter_delimiter;
@ -59,7 +60,7 @@ export function resolveFormat(collection: Collection, entry: Entry) {
if (filePath) {
const fileExtension = filePath.split('.').pop();
if (fileExtension) {
return get(extensionFormatters, fileExtension);
return extensionFormatters[fileExtension];
}
}
@ -67,7 +68,7 @@ export function resolveFormat(collection: Collection, entry: Entry) {
// collection config, infer the format from that extension.
const extension = collection.extension;
if (extension) {
return get(extensionFormatters, extension);
return extensionFormatters[extension];
}
// If no format is specified and it cannot be inferred, return the default.

View File

@ -1,8 +1,9 @@
import matter from 'gray-matter';
import tomlFormatter from './toml';
import yamlFormatter from './yaml';
import jsonFormatter from './json';
import TomlFormatter from './TomlFormatter';
import YamlFormatter from './YamlFormatter';
import JsonFormatter from './JsonFormatter';
import { FileFormatter } from './FileFormatter';
const Languages = {
YAML: 'yaml',
@ -17,10 +18,10 @@ type Format = { language: Language; delimiters: Delimiter };
const parsers = {
toml: {
parse: (input: string) => tomlFormatter.fromFile(input),
parse: (input: string) => TomlFormatter.fromFile(input),
stringify: (metadata: object, opts?: { sortedKeys?: string[] }) => {
const { sortedKeys } = opts || {};
return tomlFormatter.toFile(metadata, sortedKeys);
return TomlFormatter.toFile(metadata, sortedKeys);
},
},
json: {
@ -30,10 +31,10 @@ const parsers = {
if (JSONinput.slice(0, 1) !== '{') {
JSONinput = '{' + JSONinput + '}';
}
return jsonFormatter.fromFile(JSONinput);
return JsonFormatter.fromFile(JSONinput);
},
stringify: (metadata: object) => {
let JSONoutput = jsonFormatter.toFile(metadata).trim();
let JSONoutput = JsonFormatter.toFile(metadata).trim();
// Trim leading and trailing brackets.
if (JSONoutput.slice(0, 1) === '{' && JSONoutput.slice(-1) === '}') {
JSONoutput = JSONoutput.slice(1, -1);
@ -42,13 +43,13 @@ const parsers = {
},
},
yaml: {
parse: (input: string) => yamlFormatter.fromFile(input),
parse: (input: string) => YamlFormatter.fromFile(input),
stringify: (
metadata: object,
opts?: { sortedKeys?: string[]; comments?: Record<string, string> },
) => {
const { sortedKeys, comments } = opts || {};
return yamlFormatter.toFile(metadata, sortedKeys, comments);
return YamlFormatter.toFile(metadata, sortedKeys, comments);
},
},
};
@ -91,10 +92,11 @@ export function getFormatOpts(format?: Language, customDelimiter?: Delimiter) {
};
}
export class FrontmatterFormatter {
export class FrontmatterFormatter extends FileFormatter {
format?: Format;
constructor(format?: Language, customDelimiter?: Delimiter) {
super();
this.format = getFormatOpts(format, customDelimiter);
}

View File

@ -1,9 +0,0 @@
export default {
fromFile(content: string) {
return JSON.parse(content);
},
toFile(data: object) {
return JSON.stringify(data, null, 2);
},
};

View File

@ -75,13 +75,13 @@ export type ValueOrNestedValue =
export type EntryData = ObjectValue | undefined | null;
export interface Entry {
export interface Entry<T = ObjectValue> {
collection: string;
slug: string;
path: string;
partial: boolean;
raw: string;
data: EntryData;
data: T | undefined | null;
label: string | null;
isModification: boolean | null;
mediaFiles: MediaFile[];
@ -277,10 +277,10 @@ export type WidgetPreviewComponent<T = unknown, F extends Field = Field> =
| React.ReactElement<unknown, string | React.JSXElementConstructor<any>>
| ComponentType<WidgetPreviewProps<T, F>>;
export interface TemplatePreviewProps {
export interface TemplatePreviewProps<T = unknown> {
collection: Collection;
fields: Field[];
entry: Entry;
entry: Entry<T>;
document: Document | undefined | null;
window: Window | undefined | null;
getAsset: GetAssetFunction;

View File

@ -49,7 +49,6 @@ function entryDraftReducer(
switch (action.type) {
case DRAFT_CREATE_FROM_ENTRY: {
const newState = { ...state };
delete newState.localBackup;
// Existing Entry
return {

View File

@ -66,9 +66,11 @@ const MarkdownControl = ({
const handleOnChange = useCallback(() => {
const newValue = editorRef.current?.getInstance().getMarkdown() ?? '';
setInternalValue(newValue);
onChange(newValue);
}, [editorRef, onChange]);
if (newValue !== internalValue) {
setInternalValue(newValue);
onChange(newValue);
}
}, [editorRef, internalValue, onChange]);
const handleLabelClick = useCallback(() => {
editorRef.current?.getInstance().focus();
@ -151,7 +153,7 @@ const MarkdownControl = ({
addMedia();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [field, mediaPath]);
}, [mediaPath]);
const { initialEditType, height, ...markdownEditorOptions } = useEditorOptions();

View File

@ -19,8 +19,11 @@ const MarkdownPreview = ({
const mediaHolder = useMemo(() => new MediaHolder(), []);
const media = useMedia({ value, getAsset, field });
const viewer = useRef<Viewer | null>(null);
useEffect(() => {
mediaHolder.setBulkMedia(media);
viewer.current?.getInstance().setMarkdown(value ?? '');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [media]);
@ -31,12 +34,6 @@ const MarkdownPreview = ({
mode: 'preview',
});
const viewer = useRef<Viewer | null>(null);
useEffect(() => {
viewer.current?.getInstance().setMarkdown(value ?? '');
}, [value, media]);
return useMemo(() => {
if (!value) {
return null;

View File

@ -48,7 +48,7 @@ const useMedia = ({ value, getAsset, field }: UseMediaProps) => {
}
const uniqueMediaToLoad = mediaToLoad.filter(
(value, index, self) => self.indexOf(value) === index,
(value, index, self) => self.indexOf(value) === index && !(value in media),
);
for (const url of uniqueMediaToLoad) {

View File

@ -113,6 +113,7 @@ const RelationControl = ({
query,
locale,
label,
hasErrors
}: WidgetControlProps<string | string[], RelationField>) => {
const [internalValue, setInternalValue] = useState(value);
const [initialOptions, setInitialOptions] = useState<HitOption[]>([]);
@ -227,6 +228,7 @@ const RelationControl = ({
key="relation-control-input"
{...params}
label={label}
error={hasErrors}
InputProps={{
...params.InputProps,
endAdornment: (

View File

@ -7207,7 +7207,7 @@ prompts@^2.0.1:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@ -7458,11 +7458,6 @@ react-is@^16.13.1, react-is@^16.7.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-lifecycles-compat@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-polyglot@0.7.2:
version "0.7.2"
resolved "https://registry.yarnpkg.com/react-polyglot/-/react-polyglot-0.7.2.tgz#b0277688c6d26eaff3312774738859cac53a227c"
@ -7519,22 +7514,6 @@ react-sortable-hoc@2.0.0:
invariant "^2.2.4"
prop-types "^15.5.7"
react-split-pane@0.1.92:
version "0.1.92"
resolved "https://registry.yarnpkg.com/react-split-pane/-/react-split-pane-0.1.92.tgz#68242f72138aed95dd5910eeb9d99822c4fc3a41"
integrity sha512-GfXP1xSzLMcLJI5BM36Vh7GgZBpy+U/X0no+VM3fxayv+p1Jly5HpMofZJraeaMl73b3hvlr+N9zJKvLB/uz9w==
dependencies:
prop-types "^15.7.2"
react-lifecycles-compat "^3.0.4"
react-style-proptype "^3.2.2"
react-style-proptype@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/react-style-proptype/-/react-style-proptype-3.2.2.tgz#d8e998e62ce79ec35b087252b90f19f1c33968a0"
integrity sha512-ywYLSjNkxKHiZOqNlso9PZByNEY+FTyh3C+7uuziK0xFXu9xzdyfHwg4S9iyiRRoPCR4k2LqaBBsWVmSBwCWYQ==
dependencies:
prop-types "^15.5.4"
react-svg-core@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/react-svg-core/-/react-svg-core-3.0.3.tgz#5d856efeaa4d089b0afeebe885b20b8c9500d162"