feat: local backup enhancements (#714)
This commit is contained in:
committed by
GitHub
parent
39bb9647b2
commit
804c09415b
@ -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>
|
||||
);
|
||||
|
@ -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>;
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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}>
|
||||
|
@ -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
|
||||
`,
|
||||
),
|
||||
},
|
||||
|
@ -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}
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -280,6 +280,7 @@ const EditorInterface = ({
|
||||
togglePreview={handleTogglePreview}
|
||||
toggleScrollSync={handleToggleScrollSync}
|
||||
toggleI18n={handleToggleI18n}
|
||||
slug={slug}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
@ -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>
|
||||
),
|
||||
|
Reference in New Issue
Block a user