feat: local backup enhancements (#714)

This commit is contained in:
Daniel Lautzenheiser
2023-04-19 13:30:21 -04:00
committed by GitHub
parent 39bb9647b2
commit 804c09415b
49 changed files with 348 additions and 140 deletions

View File

@ -1,14 +1,17 @@
import { Info as InfoIcon } from '@styled-icons/material-outlined/Info';
import get from 'lodash/get';
import React, { useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { VIEW_STYLE_LIST } from '@staticcms/core/constants/views';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import { getFieldPreview, getPreviewCard } from '@staticcms/core/lib/registry';
import { getEntryBackupKey } from '@staticcms/core/lib/util/backup.util';
import {
selectEntryCollectionTitle,
selectFields,
selectTemplateName,
} from '@staticcms/core/lib/util/collection.util';
import localForage from '@staticcms/core/lib/util/localForage';
import { isNullish } from '@staticcms/core/lib/util/null.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
@ -22,7 +25,15 @@ import TableRow from '../../common/table/TableRow';
import useWidgetsFor from '../../common/widget/useWidgetsFor';
import type { ViewStyle } from '@staticcms/core/constants/views';
import type { Collection, Entry, FileOrImageField, MediaField } from '@staticcms/core/interface';
import type {
BackupEntry,
Collection,
Entry,
FileOrImageField,
MediaField,
TranslatedProps,
} from '@staticcms/core/interface';
import type { FC } from 'react';
export interface EntryCardProps {
entry: Entry;
@ -33,14 +44,15 @@ export interface EntryCardProps {
summaryFields: string[];
}
const EntryCard = ({
const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
collection,
entry,
collectionLabel,
viewStyle = VIEW_STYLE_LIST,
imageFieldName,
summaryFields,
}: EntryCardProps) => {
t,
}) => {
const entryData = entry.data;
const path = useMemo(
@ -86,6 +98,31 @@ const EntryCard = ({
const theme = useAppSelector(selectTheme);
const [hasLocalBackup, setHasLocalBackup] = useState(false);
useEffect(() => {
let alive = true;
const checkLocalBackup = async () => {
const key = getEntryBackupKey(collection.name, entry.slug);
const backup = await localForage.getItem<BackupEntry>(key);
if (alive) {
setHasLocalBackup(Boolean(backup));
}
};
checkLocalBackup();
// Check again after small delay to ensure we capture the draft just made from the editor
setTimeout(() => {
checkLocalBackup();
}, 250);
return () => {
alive = false;
};
}, [collection.name, entry.slug]);
if (viewStyle === VIEW_STYLE_LIST) {
return (
<TableRow
@ -129,6 +166,19 @@ const EntryCard = ({
</TableCell>
);
})}
<TableCell key="unsavedChanges" to={path} shrink>
{hasLocalBackup ? (
<InfoIcon
className="
w-5
h-5
text-blue-600
dark:text-blue-300
"
title={t('ui.localBackup.hasLocalBackup')}
/>
) : null}
</TableCell>
</TableRow>
);
}
@ -144,6 +194,7 @@ const EntryCard = ({
widgetFor={widgetFor}
widgetsFor={widgetsFor}
theme={theme}
hasLocalBackup={hasLocalBackup}
/>
</CardActionArea>
</Card>
@ -154,7 +205,22 @@ const EntryCard = ({
<Card>
<CardActionArea to={path}>
{image && imageField ? <CardMedia height="140" image={imageUrl} /> : null}
<CardContent>{summary}</CardContent>
<CardContent>
<div className="flex w-full items-center justify-between">
<div>{summary}</div>
{hasLocalBackup ? (
<InfoIcon
className="
w-5
h-5
text-blue-600
dark:text-blue-300
"
title={t('ui.localBackup.hasLocalBackup')}
/>
) : null}
</div>
</CardContent>
</CardActionArea>
</Card>
);

View File

@ -1,4 +1,5 @@
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import { Waypoint } from 'react-waypoint';
import { selectFields, selectInferredField } from '@staticcms/core/lib/util/collection.util';
@ -7,8 +8,15 @@ import Table from '../../common/table/Table';
import EntryCard from './EntryCard';
import type { ViewStyle } from '@staticcms/core/constants/views';
import type { Collection, Collections, Entry, Field } from '@staticcms/core/interface';
import type {
Collection,
Collections,
Entry,
Field,
TranslatedProps,
} from '@staticcms/core/interface';
import type Cursor from '@staticcms/core/lib/util/Cursor';
import type { FC } from 'react';
export interface BaseEntryListingProps {
entries: Entry[];
@ -30,14 +38,15 @@ export type EntryListingProps =
| SingleCollectionEntryListingProps
| MultipleCollectionEntryListingProps;
const EntryListing = ({
const EntryListing: FC<TranslatedProps<EntryListingProps>> = ({
entries,
page,
cursor,
viewStyle,
handleCursorActions,
t,
...otherProps
}: EntryListingProps) => {
}) => {
const hasMore = useMemo(() => cursor?.actions?.has('append_next'), [cursor?.actions]);
const handleLoadMore = useCallback(() => {
@ -95,6 +104,7 @@ const EntryListing = ({
entry={entry}
key={entry.slug}
summaryFields={summaryFields}
t={t}
/>
));
}
@ -116,10 +126,11 @@ const EntryListing = ({
collectionLabel={collectionLabel}
key={entry.slug}
summaryFields={summaryFields}
t={t}
/>
) : null;
});
}, [entries, inferFields, isSingleCollectionInList, otherProps, summaryFields, viewStyle]);
}, [entries, inferFields, isSingleCollectionInList, otherProps, summaryFields, t, viewStyle]);
const summaryFieldHeaders = useMemo(() => {
if ('collection' in otherProps) {
@ -143,7 +154,9 @@ const EntryListing = ({
<>
<Table
columns={
!isSingleCollectionInList ? ['Collection', ...summaryFieldHeaders] : summaryFieldHeaders
!isSingleCollectionInList
? ['Collection', ...summaryFieldHeaders, '']
: [...summaryFieldHeaders, '']
}
>
{renderedCards}
@ -171,4 +184,4 @@ const EntryListing = ({
);
};
export default EntryListing;
export default translate()(EntryListing) as FC<EntryListingProps>;

View File

@ -8,7 +8,7 @@ import type { CSSProperties, FC, MouseEventHandler, ReactNode, Ref } from 'react
export interface BaseBaseProps {
variant?: 'contained' | 'outlined' | 'text';
color?: 'primary' | 'secondary' | 'success' | 'error';
color?: 'primary' | 'secondary' | 'success' | 'error' | 'warning';
size?: 'medium' | 'small';
rounded?: boolean | 'no-padding';
className?: string;

View File

@ -11,18 +11,21 @@ const classes: Record<
secondary: 'btn-contained-secondary',
success: 'btn-contained-success',
error: 'btn-contained-error',
warning: 'btn-contained-warning',
},
outlined: {
primary: 'btn-outlined-primary',
secondary: 'btn-outlined-secondary',
success: 'btn-outlined-success',
error: 'btn-outlined-error',
warning: 'btn-outlined-warning',
},
text: {
primary: 'btn-text-primary',
secondary: 'btn-text-secondary',
success: 'btn-text-success',
error: 'btn-text-error',
warning: 'btn-text-warning',
},
};

View File

@ -7,7 +7,7 @@ interface CardContentProps {
}
const CardContent = ({ children }: CardContentProps) => {
return <p className="p-5 font-normal text-gray-700 dark:text-gray-300">{children}</p>;
return <p className="w-full p-5 font-normal text-gray-700 dark:text-gray-300">{children}</p>;
};
export default CardContent;

View File

@ -13,7 +13,7 @@ interface ConfirmProps {
body: string | { key: string; options?: Record<string, unknown> };
cancel?: string | { key: string; options?: Record<string, unknown> };
confirm?: string | { key: string; options?: Record<string, unknown> };
color?: 'success' | 'error' | 'primary';
color?: 'success' | 'error' | 'warning' | 'primary';
}
export interface ConfirmDialogProps extends ConfirmProps {
@ -120,7 +120,7 @@ const ConfirmDialog = ({ t }: TranslateProps) => {
gap-2
"
>
<Button onClick={handleCancel} variant="text">
<Button onClick={handleCancel} variant="text" color="secondary">
{cancel}
</Button>
<Button onClick={handleConfirm} variant="contained" color={color}>

View File

@ -13,7 +13,7 @@ export interface MenuItemButtonProps {
disabled?: boolean;
startIcon?: FC<{ className?: string }>;
endIcon?: FC<{ className?: string }>;
color?: 'default' | 'error';
color?: 'default' | 'error' | 'warning';
'data-testid'?: string;
}
@ -46,19 +46,32 @@ const MenuItemButton = ({
items-center
justify-between
cursor-pointer
hover:bg-gray-200
dark:hover:bg-slate-600
dark:disabled:text-gray-700
`,
color === 'default' &&
`
text-gray-700
dark:text-gray-300
hover:bg-gray-200
dark:hover:bg-slate-600
`,
color === 'warning' &&
`
text-yellow-600
dark:text-yellow-500
hover:text-white
hover:bg-yellow-500
dark:hover:text-yellow-100
dark:hover:bg-yellow-600
`,
color === 'error' &&
`
text-red-500
dark:text-red-500
hover:text-white
hover:bg-red-500
dark:hover:text-red-100
dark:hover:bg-red-600
`,
),
},

View File

@ -9,9 +9,10 @@ interface TableCellProps {
children: ReactNode;
emphasis?: boolean;
to?: string;
shrink?: boolean;
}
const TableCell = ({ children, emphasis = false, to }: TableCellProps) => {
const TableCell = ({ children, emphasis = false, to, shrink = false }: TableCellProps) => {
const content = useMemo(() => {
if (to) {
return (
@ -39,6 +40,7 @@ const TableCell = ({ children, emphasis = false, to }: TableCellProps) => {
!to ? 'px-4 py-3' : 'p-0',
'text-gray-500 dark:text-gray-300',
emphasis && 'font-medium text-gray-900 whitespace-nowrap dark:text-white',
shrink && 'w-0',
)}
>
{content}

View File

@ -1,10 +1,10 @@
import { createHashHistory } from 'history';
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import isEqual from 'lodash/isEqual';
import {
createDraftDuplicateFromEntry,
@ -21,8 +21,9 @@ import {
import { loadScroll, toggleScroll } from '@staticcms/core/actions/scroll';
import { selectFields } from '@staticcms/core/lib/util/collection.util';
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectEntry } from '@staticcms/core/reducers/selectors/entries';
import { useAppDispatch } from '@staticcms/core/store/hooks';
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
import confirm from '../common/confirm/Confirm';
import Loader from '../common/progress/Loader';
import MediaLibraryModal from '../media-library/MediaLibraryModal';
@ -61,23 +62,32 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
const navigate = useNavigate();
const config = useAppSelector(selectConfig);
const createBackup = useMemo(
() =>
debounce(function (entry: Entry, collection: Collection) {
if (config?.disable_local_backup) {
return;
}
dispatch(persistLocalBackup(entry, collection));
}, 2000),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
[config],
);
const deleteBackup = useCallback(() => {
if (config?.disable_local_backup) {
return;
}
createBackup.cancel();
if (slug) {
dispatch(deleteLocalBackup(collection, slug));
}
dispatch(deleteDraftLocalBackup());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collection, createBackup, slug]);
}, [config?.disable_local_backup, createBackup, slug, dispatch, collection]);
const [submitted, setSubmitted] = useState(false);
const handlePersistEntry = useCallback(
@ -166,6 +176,10 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
>();
useEffect(() => {
if (config?.disable_local_backup) {
return;
}
if (
!prevLocalBackup &&
localBackup &&
@ -173,17 +187,8 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
!isEqual(localBackup.entry.meta, entryDraft.entry?.meta))
) {
const updateLocalBackup = async () => {
const confirmLoadBackupBody = await confirm({
title: 'editor.editor.confirmLoadBackupTitle',
body: 'editor.editor.confirmLoadBackupBody',
});
if (confirmLoadBackupBody) {
dispatch(loadLocalBackup());
setVersion(version + 1);
} else {
deleteBackup();
}
dispatch(loadLocalBackup());
setVersion(version + 1);
};
updateLocalBackup();
@ -191,6 +196,7 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
setPrevLocalBackup(localBackup);
}, [
config?.disable_local_backup,
deleteBackup,
dispatch,
entryDraft.entry?.data,
@ -219,14 +225,25 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
});
} else if (!newRecord && slug && (prevCollection !== collection || prevSlug !== slug)) {
setTimeout(() => {
dispatch(retrieveLocalBackup(collection, slug));
if (!config?.disable_local_backup) {
dispatch(retrieveLocalBackup(collection, slug));
}
dispatch(loadEntry(collection, slug));
});
}
setPrevCollection(collection);
setPrevSlug(slug);
}, [collection, entryDraft.entry, prevSlug, prevCollection, slug, dispatch, newRecord]);
}, [
collection,
entryDraft.entry,
prevSlug,
prevCollection,
slug,
dispatch,
newRecord,
config?.disable_local_backup,
]);
const leaveMessage = useMemo(() => t('editor.editor.onLeavePage'), [t]);

View File

@ -280,6 +280,7 @@ const EditorInterface = ({
togglePreview={handleTogglePreview}
toggleScrollSync={handleToggleScrollSync}
toggleI18n={handleToggleI18n}
slug={slug}
/>
}
>

View File

@ -7,13 +7,16 @@ import { Height as HeightIcon } from '@styled-icons/material-rounded/Height';
import { Check as CheckIcon } from '@styled-icons/material/Check';
import { MoreVert as MoreVertIcon } from '@styled-icons/material/MoreVert';
import { Publish as PublishIcon } from '@styled-icons/material/Publish';
import React, { useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import { selectAllowDeletion } from '@staticcms/core/lib/util/collection.util';
import Menu from '../common/menu/Menu';
import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton';
import confirm from '../common/confirm/Confirm';
import { useAppDispatch } from '@staticcms/core/store/hooks';
import { deleteLocalBackup, loadEntry } from '@staticcms/core/actions/entries';
import type { Collection, EditorPersistOptions, TranslatedProps } from '@staticcms/core/interface';
import type { FC, MouseEventHandler } from 'react';
@ -39,6 +42,7 @@ export interface EditorToolbarProps {
togglePreview: MouseEventHandler;
toggleScrollSync: MouseEventHandler;
toggleI18n: MouseEventHandler;
slug?: string | undefined;
}
const EditorToolbar = ({
@ -61,6 +65,7 @@ const EditorToolbar = ({
togglePreview,
toggleScrollSync,
toggleI18n,
slug,
}: TranslatedProps<EditorToolbarProps>) => {
const canCreate = useMemo(
() => ('folder' in collection && collection.create) ?? false,
@ -69,7 +74,28 @@ const EditorToolbar = ({
const canDelete = useMemo(() => selectAllowDeletion(collection), [collection]);
const isPublished = useMemo(() => !isNewEntry && !hasChanged, [hasChanged, isNewEntry]);
const menuItems = useMemo(() => {
const dispatch = useAppDispatch();
const handleDiscardDraft = useCallback(async () => {
if (!slug) {
return;
}
if (
await confirm({
title: 'editor.editorToolbar.discardChangesTitle',
body: {
key: 'editor.editorToolbar.discardChangesBody',
},
color: 'warning',
})
) {
dispatch(deleteLocalBackup(collection, slug));
dispatch(loadEntry(collection, slug));
}
}, [collection, dispatch, slug]);
const menuItems: JSX.Element[][] = useMemo(() => {
const items: JSX.Element[] = [];
if (!isPublished) {
@ -105,8 +131,34 @@ const EditorToolbar = ({
);
}
return items;
}, [canCreate, isPublished, onDuplicate, onPersist, onPersistAndDuplicate, onPersistAndNew, t]);
if (hasChanged) {
return [
items,
[
<MenuItemButton
key="discardChanges"
onClick={handleDiscardDraft}
startIcon={TrashIcon}
color="warning"
>
{t('editor.editorToolbar.discardChanges')}
</MenuItemButton>,
],
];
}
return [items];
}, [
canCreate,
handleDiscardDraft,
hasChanged,
isPublished,
onDuplicate,
onPersist,
onPersistAndDuplicate,
onPersistAndNew,
t,
]);
return useMemo(
() => (
@ -166,9 +218,11 @@ const EditorToolbar = ({
isPublished ? t('editor.editorToolbar.published') : t('editor.editorToolbar.publish')
}
color={isPublished ? 'success' : 'primary'}
disabled={menuItems.length == 0}
disabled={menuItems.length == 1 && menuItems[0].length === 0}
>
<MenuGroup>{menuItems}</MenuGroup>
{menuItems.map((group, index) => (
<MenuGroup key={`menu-group-${index}`}>{group}</MenuGroup>
))}
</Menu>
</div>
),