fix: draft folder/file handling (#777)

This commit is contained in:
Daniel Lautzenheiser 2023-05-04 14:36:33 -04:00 committed by GitHub
parent cd13f3d193
commit 95010a5cce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 505 additions and 63 deletions

View File

@ -58,6 +58,8 @@ collections:
name: image name: image
widget: image widget: image
required: false required: false
media_library:
folder_support: true
- label: Body - label: Body
name: body name: body
widget: markdown widget: markdown

View File

@ -556,18 +556,20 @@ export function retrieveLocalBackup(collection: Collection, slug: string) {
// load assets from backup // load assets from backup
const mediaFiles = entry.mediaFiles || []; const mediaFiles = entry.mediaFiles || [];
const assetProxies: AssetProxy[] = await Promise.all( const assetProxies: AssetProxy[] = await Promise.all(
mediaFiles.map(file => { mediaFiles
if (file.file || file.url) { .filter(file => !file.isDirectory)
return createAssetProxy({ .map(file => {
path: file.path, if (file.file || file.url) {
file: file.file, return createAssetProxy({
url: file.url, path: file.path,
field: file.field, file: file.file,
}); url: file.url,
} else { field: file.field,
return getAsset(collection, entry, file.path, file.field)(dispatch, getState); });
} } else {
}), return getAsset(collection, entry, file.path, file.field)(dispatch, getState);
}
}),
); );
dispatch(addAssets(assetProxies)); dispatch(addAssets(assetProxies));

View File

@ -270,7 +270,7 @@ export function persistMedia(
let mediaFile: ImplementationMediaFile; let mediaFile: ImplementationMediaFile;
if (editingDraft) { if (editingDraft) {
const id = await getBlobSHA(file); const id = `${assetProxy.path}/${await getBlobSHA(file)}`;
mediaFile = createMediaFileFromAsset({ mediaFile = createMediaFileFromAsset({
id, id,
file, file,

View File

@ -33,12 +33,18 @@ const FolderCreationDialog: FC<TranslatedProps<FolderCreationDialogProps>> = ({
} }
onCreate(folderName); onCreate(folderName);
setFolderName('');
}, [folderName, onCreate]); }, [folderName, onCreate]);
const handleClose = useCallback(() => {
onClose();
setFolderName('');
}, [onClose]);
return ( return (
<Modal <Modal
open={open} open={open}
onClose={onClose} onClose={handleClose}
className=" className="
w-[50%] w-[50%]
min-w-[300px] min-w-[300px]
@ -65,7 +71,7 @@ const FolderCreationDialog: FC<TranslatedProps<FolderCreationDialogProps>> = ({
> >
{t('mediaLibrary.folderSupport.createNewFolder')} {t('mediaLibrary.folderSupport.createNewFolder')}
</h3> </h3>
<IconButton variant="text" aria-label="add" onClick={onClose}> <IconButton variant="text" aria-label="add" onClick={handleClose}>
<CloseIcon className="w-5 h-5" /> <CloseIcon className="w-5 h-5" />
</IconButton> </IconButton>
</div> </div>
@ -96,7 +102,7 @@ const FolderCreationDialog: FC<TranslatedProps<FolderCreationDialogProps>> = ({
space-x-2 space-x-2
" "
> >
<Button variant="text" aria-label="cancel" onClick={onClose}> <Button variant="text" aria-label="cancel" onClick={handleClose}>
Cancel Cancel
</Button> </Button>
<Button <Button

View File

@ -62,7 +62,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
t, t,
}: TranslatedProps<MediaLibraryCardProps<T, EF>>) => { }: TranslatedProps<MediaLibraryCardProps<T, EF>>) => {
const entry = useAppSelector(selectEditingDraft); const entry = useAppSelector(selectEditingDraft);
const url = useMediaAsset(path, collection, field, entry, currentFolder); const url = useMediaAsset(path, collection, field, entry, currentFolder, isDirectory);
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
const url = displayURL.url; const url = displayURL.url;

View File

@ -0,0 +1,427 @@
/**
* @jest-environment jsdom
*/
import '@testing-library/jest-dom';
import { act, render } from '@testing-library/react';
import React from 'react';
import { currentBackend } from '@staticcms/core/backend';
import { selectCollection } from '@staticcms/core/reducers/selectors/collections';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import { selectMediaLibraryFiles } from '@staticcms/core/reducers/selectors/mediaLibrary';
import { useAppSelector } from '@staticcms/core/store/hooks';
import { createMockCollection } from '@staticcms/test/data/collections.mock';
import { createMockConfig } from '@staticcms/test/data/config.mock';
import { createMockEntry } from '@staticcms/test/data/entry.mock';
import useMediaFiles from '../useMediaFiles';
import { mockFileField } from '@staticcms/test/data/fields.mock';
import type { MediaField, MediaFile } from '@staticcms/core/interface';
import type { FC } from 'react';
interface MockWidgetProps {
field?: MediaField;
currentFolder?: string;
}
jest.mock('@staticcms/core/reducers/selectors/collections');
jest.mock('@staticcms/core/reducers/selectors/config');
jest.mock('@staticcms/core/reducers/selectors/entryDraft');
jest.mock('@staticcms/core/reducers/selectors/mediaLibrary');
jest.mock('@staticcms/core/backend');
jest.mock('@staticcms/core/store/hooks');
const MockWidget: FC<MockWidgetProps> = ({ field, currentFolder }) => {
const files = useMediaFiles(field, currentFolder);
return (
<div data-testid="files">
<div data-testid="file-count">{files.length}</div>
{files.map(file => (
<div key={file.path} data-testid={file.path}>
{file.path}
</div>
))}
</div>
);
};
const testMediaFiles: MediaFile[] = [
{
name: 'file.txt',
id: 'file.txt',
path: 'path/to/file.txt',
},
{
name: 'other-file.png',
id: 'other-file.png',
path: 'path/to/other-file.png',
},
{
name: '.gitkeep',
id: '.gitkeep',
path: 'path/to/.gitkeep',
},
{
name: 'A Directory',
id: 'A Directory',
path: 'path/to/A Directory',
isDirectory: true,
},
];
const testEntryMediaFiles: Record<string, MediaFile[]> = {
'path/to': [
{
name: 'file-entry.txt',
id: 'file-entry.txt',
path: 'path/to/file-entry.txt',
},
{
name: 'other-entry-file.png',
id: 'other-entry-file.png',
path: 'path/to/other-entry-file.png',
},
{
name: '.gitkeep',
id: '.gitkeep',
path: 'path/to/.gitkeep',
},
{
name: 'An Entry Directory',
id: 'An Entry Directory',
path: 'path/to/An Entry Directory',
isDirectory: true,
},
{
name: 'An Empty Directory',
id: 'An Empty Directory',
path: 'path/to/An Empty Entry Directory',
isDirectory: true,
},
],
'path/to/An Entry Directory': [
{
name: '.gitkeep',
id: '.gitkeep',
path: 'path/to/An Entry Directory/.gitkeep',
},
{
name: 'sub-folder-file.jpg',
id: 'sub-folder-file.jpg',
path: 'path/to/An Entry Directory/sub-folder-file.jpg',
},
],
'path/to/An Empty Entry Directory': [
{
name: '.gitkeep',
id: '.gitkeep',
path: 'path/to/An Empty Entry Directory/.gitkeep',
},
],
};
describe('useMediaFiles', () => {
const mockSelectCollection = selectCollection as jest.Mock;
const mockSelectConfig = selectConfig as jest.Mock;
const mockSelectEditingDraft = selectEditingDraft as jest.Mock;
const mockSelectMediaLibraryFiles = selectMediaLibraryFiles as jest.Mock;
const mockCurrentBackend = currentBackend as jest.Mock;
const mockUseAppSelector = useAppSelector as jest.Mock;
const mockGetMedia = jest.fn();
const mockCollection = createMockCollection();
const createMockComponent = async (props: MockWidgetProps = {}) => {
const { rerender, ...result } = render(<MockWidget {...props} />);
await act(async () => {
await Promise.resolve();
});
const rerenderMockComponent = async (rerenderProps: Partial<MockWidgetProps> = {}) => {
rerender(<MockWidget {...props} {...rerenderProps} />);
await act(async () => {
await Promise.resolve();
});
};
return { ...result, rerender: rerenderMockComponent };
};
beforeEach(() => {
jest.useFakeTimers();
mockUseAppSelector.mockImplementation((fn: () => unknown) => {
if (typeof fn !== 'function') {
return undefined;
}
return fn();
});
mockSelectCollection.mockReturnValue(() => undefined);
mockSelectConfig.mockReturnValue(
createMockConfig({
collections: [mockCollection],
media_folder: 'path/to',
public_folder: 'public/path',
}),
);
mockSelectEditingDraft.mockReturnValue(undefined);
mockSelectMediaLibraryFiles.mockReturnValue(testMediaFiles);
mockCurrentBackend.mockReturnValue({
getMedia: mockGetMedia,
});
mockGetMedia.mockImplementation(path => Promise.resolve(testEntryMediaFiles[path] ?? []));
});
afterAll(() => {
jest.useRealTimers();
});
describe('top level', () => {
it('should retrieve media files, ignoring .gitkeep files', async () => {
const { getByTestId } = await createMockComponent();
expect(getByTestId('file-count').textContent).toBe('2');
expect(getByTestId('path/to/file.txt')).toBeInTheDocument();
expect(getByTestId('path/to/other-file.png')).toBeInTheDocument();
expect(mockGetMedia).not.toHaveBeenCalled();
});
it('shows folders when folder support is on', async () => {
mockSelectConfig.mockReturnValue(
createMockConfig({
collections: [mockCollection],
media_library: { folder_support: true },
}),
);
const { getByTestId } = await createMockComponent();
expect(getByTestId('file-count').textContent).toBe('3');
expect(getByTestId('path/to/file.txt')).toBeInTheDocument();
expect(getByTestId('path/to/other-file.png')).toBeInTheDocument();
expect(getByTestId('path/to/A Directory')).toBeInTheDocument();
expect(mockGetMedia).not.toHaveBeenCalled();
});
});
describe('entry', () => {
beforeEach(() => {
mockSelectEditingDraft.mockReturnValue(createMockEntry({ data: {} }));
mockSelectCollection.mockReturnValue(() => mockCollection);
});
it('should retrieve media files, ignoring .gitkeep files', async () => {
const { getByTestId } = await createMockComponent({
field: mockFileField,
currentFolder: 'path/to',
});
expect(getByTestId('file-count').textContent).toBe('2');
expect(getByTestId('path/to/file-entry.txt')).toBeInTheDocument();
expect(getByTestId('path/to/other-entry-file.png')).toBeInTheDocument();
expect(mockGetMedia).toHaveBeenCalledWith('path/to', false, 'public/path');
});
it('shows folders when folder support is on', async () => {
const { getByTestId } = await createMockComponent({
field: { ...mockFileField, media_library: { folder_support: true } },
currentFolder: 'path/to',
});
expect(getByTestId('file-count').textContent).toBe('4');
expect(getByTestId('path/to/file-entry.txt')).toBeInTheDocument();
expect(getByTestId('path/to/other-entry-file.png')).toBeInTheDocument();
expect(getByTestId('path/to/An Entry Directory')).toBeInTheDocument();
expect(getByTestId('path/to/An Empty Entry Directory')).toBeInTheDocument();
expect(mockGetMedia).toHaveBeenCalledWith('path/to', true, 'public/path');
});
it('should retrieve sub folder media files, ignoring .gitkeep files', async () => {
const { getByTestId } = await createMockComponent({
field: mockFileField,
currentFolder: 'path/to/An Entry Directory',
});
expect(getByTestId('file-count').textContent).toBe('1');
expect(getByTestId('path/to/An Entry Directory/sub-folder-file.jpg')).toBeInTheDocument();
expect(mockGetMedia).toHaveBeenCalledWith(
'path/to/An Entry Directory',
false,
'public/path/An Entry Directory',
);
});
it('should return no files for empty directory, ignoring .gitkeep files', async () => {
const { getByTestId } = await createMockComponent({
field: mockFileField,
currentFolder: 'path/to/An Empty Entry Directory',
});
expect(getByTestId('file-count').textContent).toBe('0');
expect(mockGetMedia).toHaveBeenCalledWith(
'path/to/An Empty Entry Directory',
false,
'public/path/An Empty Entry Directory',
);
});
it('should retrieve media as user transitions through folders', async () => {
const { getByTestId, rerender } = await createMockComponent({
field: { ...mockFileField, media_library: { folder_support: true } },
currentFolder: 'path/to',
});
expect(getByTestId('file-count').textContent).toBe('4');
expect(getByTestId('path/to/file-entry.txt')).toBeInTheDocument();
expect(getByTestId('path/to/other-entry-file.png')).toBeInTheDocument();
expect(getByTestId('path/to/An Entry Directory')).toBeInTheDocument();
expect(getByTestId('path/to/An Empty Entry Directory')).toBeInTheDocument();
expect(mockGetMedia).toHaveBeenLastCalledWith('path/to', true, 'public/path');
const promise = rerender({
currentFolder: 'path/to/An Entry Directory',
});
expect(getByTestId('file-count').textContent).toBe('0');
await promise;
expect(getByTestId('file-count').textContent).toBe('1');
expect(getByTestId('path/to/An Entry Directory/sub-folder-file.jpg')).toBeInTheDocument();
expect(mockGetMedia).toHaveBeenCalledWith(
'path/to/An Entry Directory',
true,
'public/path/An Entry Directory',
);
const promise2 = await rerender({
currentFolder: 'path/to/An Empty Entry Directory',
});
expect(getByTestId('file-count').textContent).toBe('0');
await promise2;
expect(getByTestId('file-count').textContent).toBe('0');
expect(mockGetMedia).toHaveBeenCalledWith(
'path/to/An Empty Entry Directory',
true,
'public/path/An Empty Entry Directory',
);
});
it('should retrieve media as user transitions through draft folders', async () => {
mockSelectEditingDraft.mockReturnValue(
createMockEntry({
data: {},
mediaFiles: [
{
name: '.gitkeep',
id: '.gitkeep',
path: 'path/to/Draft Folder/.gitkeep',
draft: true,
},
{
name: '.gitkeep',
id: '.gitkeep',
path: 'path/to/Draft Folder/Sub Folder/.gitkeep',
draft: true,
},
{
name: 'image.gif',
id: 'image.gif',
path: 'path/to/Draft Folder/Sub Folder/image.gif',
draft: true,
},
],
}),
);
const { getByTestId, rerender } = await createMockComponent({
field: { ...mockFileField, media_library: { folder_support: true } },
currentFolder: 'path/to',
});
expect(getByTestId('file-count').textContent).toBe('5');
expect(getByTestId('path/to/file-entry.txt')).toBeInTheDocument();
expect(getByTestId('path/to/other-entry-file.png')).toBeInTheDocument();
expect(getByTestId('path/to/An Entry Directory')).toBeInTheDocument();
expect(getByTestId('path/to/An Empty Entry Directory')).toBeInTheDocument();
expect(getByTestId('path/to/Draft Folder')).toBeInTheDocument();
expect(mockGetMedia).toHaveBeenLastCalledWith('path/to', true, 'public/path');
const promise = rerender({
currentFolder: 'path/to/Draft Folder',
});
// Draft files/folders should appear immediately
expect(getByTestId('file-count').textContent).toBe('1');
expect(getByTestId('path/to/Draft Folder/Sub Folder')).toBeInTheDocument();
await promise;
expect(getByTestId('file-count').textContent).toBe('1');
expect(getByTestId('path/to/Draft Folder/Sub Folder')).toBeInTheDocument();
expect(mockGetMedia).toHaveBeenCalledWith(
'path/to/Draft Folder',
true,
'public/path/Draft Folder',
);
const promise2 = await rerender({
currentFolder: 'path/to/Draft Folder/Sub Folder',
});
// Draft files/folders should appear immediately
expect(getByTestId('file-count').textContent).toBe('1');
expect(getByTestId('path/to/Draft Folder/Sub Folder/image.gif')).toBeInTheDocument();
await promise2;
expect(getByTestId('file-count').textContent).toBe('1');
expect(getByTestId('path/to/Draft Folder/Sub Folder/image.gif')).toBeInTheDocument();
expect(mockGetMedia).toHaveBeenCalledWith(
'path/to/Draft Folder/Sub Folder',
true,
'public/path/Draft Folder/Sub Folder',
);
const promise3 = await rerender({
currentFolder: 'path/to/Draft Folder',
});
// Draft files/folders should appear immediately
expect(getByTestId('file-count').textContent).toBe('1');
expect(getByTestId('path/to/Draft Folder/Sub Folder')).toBeInTheDocument();
await promise3;
expect(getByTestId('file-count').textContent).toBe('1');
expect(getByTestId('path/to/Draft Folder/Sub Folder')).toBeInTheDocument();
expect(mockGetMedia).toHaveBeenCalledWith(
'path/to/Draft Folder',
true,
'public/path/Draft Folder',
);
});
});
});

View File

@ -19,6 +19,7 @@ export default function useMediaAsset<T extends MediaField, EF extends BaseField
field?: T, field?: T,
entry?: Entry, entry?: Entry,
currentFolder?: string, currentFolder?: string,
isDirectory?: boolean,
): string { ): string {
const isAbsolute = useMemo( const isAbsolute = useMemo(
() => (isNotEmpty(url) ? /^(?:[a-z+]+:)?\/\//g.test(url) : false), () => (isNotEmpty(url) ? /^(?:[a-z+]+:)?\/\//g.test(url) : false),
@ -30,7 +31,7 @@ export default function useMediaAsset<T extends MediaField, EF extends BaseField
const debouncedUrl = useDebounce(url, 200); const debouncedUrl = useDebounce(url, 200);
useEffect(() => { useEffect(() => {
if (!debouncedUrl || isAbsolute || debouncedUrl.startsWith('blob:')) { if (!debouncedUrl || isAbsolute || debouncedUrl.startsWith('blob:') || isDirectory) {
return; return;
} }

View File

@ -41,10 +41,6 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
let alive = true; let alive = true;
const getMediaFiles = async () => { const getMediaFiles = async () => {
if (entry.mediaFiles.find(f => dirname(f.path) == currentFolder)?.draft) {
setCurrentFolderMediaFiles([]);
return;
}
const { media_folder, public_folder } = config ?? {}; const { media_folder, public_folder } = config ?? {};
const backend = currentBackend(config); const backend = currentBackend(config);
const files = await backend.getMedia( const files = await backend.getMedia(
@ -61,6 +57,7 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
}; };
getMediaFiles(); getMediaFiles();
setCurrentFolderMediaFiles([]);
return () => { return () => {
alive = false; alive = false;
@ -68,46 +65,47 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
}, [currentFolder, config, entry, folderSupport]); }, [currentFolder, config, entry, folderSupport]);
const files = useMemo(() => { const files = useMemo(() => {
if (entry) { if (!entry || !config) {
const entryFiles = entry.mediaFiles ?? []; return mediaLibraryFiles ?? [];
if (config) {
const mediaFolder = selectMediaFolder(config, collection, entry, field, currentFolder);
const entryFolderFiles = entryFiles
.filter(f => {
if (f.name === '.gitkeep') {
const folder = dirname(f.path);
return dirname(folder) === mediaFolder;
}
return dirname(f.path) === mediaFolder;
})
.map(file => {
if (file.name === '.gitkeep') {
const folder = dirname(file.path);
return {
key: folder,
id: folder,
name: basename(folder),
path: folder,
isDirectory: true,
draft: true,
} as MediaFile;
}
return { key: file.id, ...file };
});
if (currentFolderMediaFiles) {
if (entryFiles.length > 0) {
const draftFiles = entryFolderFiles.filter(file => file.draft == true);
currentFolderMediaFiles.unshift(...draftFiles);
}
return currentFolderMediaFiles.map(file => ({ key: file.id, ...file }));
}
return entryFolderFiles;
}
} }
return mediaLibraryFiles ?? []; const entryFiles = entry.mediaFiles ?? [];
const mediaFolder = selectMediaFolder(config, collection, entry, field, currentFolder);
const entryFolderFiles = entryFiles
.filter(f => {
if (f.name === '.gitkeep') {
const folder = dirname(f.path);
return dirname(folder) === mediaFolder;
}
return dirname(f.path) === mediaFolder;
})
.map(file => {
if (file.name === '.gitkeep') {
const folder = dirname(file.path);
return {
key: folder,
id: folder,
name: basename(folder),
path: folder,
isDirectory: true,
draft: true,
} as MediaFile;
}
return { key: file.id, ...file };
});
if (currentFolderMediaFiles) {
const files = [...currentFolderMediaFiles];
if (entryFiles.length > 0) {
const draftFiles = entryFolderFiles.filter(
file => file.draft == true && !files.find(f => f.id === file.id),
);
files.unshift(...draftFiles);
}
return files.map(file => ({ key: file.id, ...file }));
}
return entryFolderFiles;
}, [collection, config, currentFolderMediaFiles, entry, field, mediaLibraryFiles, currentFolder]); }, [collection, config, currentFolderMediaFiles, entry, field, mediaLibraryFiles, currentFolder]);
return useMemo( return useMemo(

View File

@ -1,12 +1,12 @@
import { sha256 } from 'js-sha256'; import { sha256 } from 'js-sha256';
export default (blob: Blob): Promise<string> => export default (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => { new Promise(resolve => {
const fr = new FileReader(); const fr = new FileReader();
fr.onload = ({ target }) => resolve(sha256(target?.result || '')); fr.onload = ({ target }) => resolve(sha256(target?.result || ''));
fr.onerror = err => { fr.onerror = () => {
fr.abort(); fr.abort();
reject(err); resolve('');
}; };
fr.readAsArrayBuffer(blob); fr.readAsArrayBuffer(blob);
}); });

View File

@ -49,6 +49,12 @@ export const mockFileField: FileOrImageField = {
widget: 'file', widget: 'file',
}; };
export const mockImageField: FileOrImageField = {
label: 'Image',
name: 'mock_image',
widget: 'image',
};
export const mockMarkdownField: MarkdownField = { export const mockMarkdownField: MarkdownField = {
label: 'Body', label: 'Body',
name: 'body', name: 'body',