fix: folder collection path (#549)

This commit is contained in:
Daniel Lautzenheiser 2023-02-16 13:34:35 -05:00 committed by GitHub
parent 93915dac35
commit 8f7237ab7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 742 additions and 513 deletions

1
BREAKING_CHANGES.md Normal file
View File

@ -0,0 +1 @@
- useMediaInsert now requires collection to be passed

View File

@ -1,9 +1,9 @@
{ {
"front_limit": 5, "front_limit": 6,
"site_title": "Test", "site_title": "Test",
"posts": { "posts": {
"front_limit": 4, "front_limit": 4,
"author": "Bob", "author": "Bob",
"thumb": "/backends/proxy/assets/upload/kanefreeman_2.jpg" "thumb": "/backends/proxy/assets/upload/kanefreeman_2.jpg"
} }
} }

View File

@ -2,7 +2,7 @@
title: Something something something... title: Something something something...
draft: false draft: false
date: 2022-11-01 06:30 date: 2022-11-01 06:30
image: /backends/proxy/assets/posts/ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg image: ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg
--- ---
# Welcome # Welcome

View File

@ -1,13 +0,0 @@
---
title: Test
draft: false
date: 2022-11-01 14:28
image: /backends/proxy/assets/posts/kittens.jpg
---
Test2
<br>
![moby-dick.jpg](/assets/upload/moby-dick.jpg)
![moby-dick.jpg](/assets/upload/moby-dick.jpg)
![kanefreeman_2.jpg](/assets/upload/kanefreeman_2.jpg)

View File

@ -0,0 +1,11 @@
---
title: Test
draft: false
date: 2022-11-01 14:28
image: kittens.jpg
---
Test234t6
[Test](https://example.com)
![Kittens!](kittens.jpg)

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

View File

@ -1,7 +0,0 @@
---
title: Test3
draft: false
date: 2022-11-02 08:43
image: /backends/proxy/assets/upload/kanefreeman_2.jpg
---
test25555

View File

@ -0,0 +1,7 @@
---
title: Test3
draft: false
date: 2022-11-02 08:43
image: ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg
---
test2555556

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

View File

@ -24,14 +24,15 @@ collections:
- name: posts - name: posts
label: Posts label: Posts
label_singular: Post label_singular: Post
media_folder: /packages/core/dev-test/backends/proxy/assets/posts
public_folder: /backends/proxy/assets/posts
description: > description: >
The description is a great place for tone setting, high level information, The description is a great place for tone setting, high level information,
and editing guidelines that are specific to a collection. and editing guidelines that are specific to a collection.
folder: packages/core/dev-test/backends/proxy/_posts folder: packages/core/dev-test/backends/proxy/_posts
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}' summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
media_folder: ""
public_folder: ""
path: "{{year}}-{{month}}-{{day}}-{{slug}}/index"
sortable_fields: sortable_fields:
fields: fields:
- title - title

View File

@ -44,7 +44,7 @@ import { Cursor } from '../lib/util';
import { selectFields, updateFieldByKey } from '../lib/util/collection.util'; import { selectFields, updateFieldByKey } from '../lib/util/collection.util';
import { selectCollectionEntriesCursor } from '../reducers/selectors/cursors'; import { selectCollectionEntriesCursor } from '../reducers/selectors/cursors';
import { import {
selectEntriesSortFields, selectEntriesSortField,
selectIsFetching, selectIsFetching,
selectPublishedSlugs, selectPublishedSlugs,
} from '../reducers/selectors/entries'; } from '../reducers/selectors/entries';
@ -653,10 +653,9 @@ export function loadEntries(collection: Collection, page = 0) {
return; return;
} }
const state = getState(); const state = getState();
const sortFields = selectEntriesSortFields(state, collection.name); const sortField = selectEntriesSortField(collection.name)(state);
if (sortFields && sortFields.length > 0) { if (sortField) {
const field = sortFields[0]; return dispatch(sortByField(collection, sortField.key, sortField.direction));
return dispatch(sortByField(collection, field.key, field.direction));
} }
const configState = state.config; const configState = state.config;
@ -956,7 +955,7 @@ export function persistEntry(collection: Collection) {
const state = getState(); const state = getState();
const entryDraft = state.entryDraft; const entryDraft = state.entryDraft;
const fieldsErrors = entryDraft.fieldsErrors; const fieldsErrors = entryDraft.fieldsErrors;
const usedSlugs = selectPublishedSlugs(state, collection.name); const usedSlugs = selectPublishedSlugs(collection.name)(state);
// Early return if draft contains validation errors // Early return if draft contains validation errors
if (Object.keys(fieldsErrors).length > 0) { if (Object.keys(fieldsErrors).length > 0) {

View File

@ -6,11 +6,9 @@ import {
LOAD_ASSET_SUCCESS, LOAD_ASSET_SUCCESS,
REMOVE_ASSET, REMOVE_ASSET,
} from '../constants'; } from '../constants';
import { isAbsolutePath } from '../lib/util';
import { selectMediaFilePath } from '../lib/util/media.util'; import { selectMediaFilePath } from '../lib/util/media.util';
import { selectMediaFileByPath } from '../reducers/selectors/mediaLibrary';
import { createAssetProxy } from '../valueObjects/AssetProxy'; import { createAssetProxy } from '../valueObjects/AssetProxy';
import { getMediaDisplayURL, getMediaFile, waitForMediaLibraryToLoad } from './mediaLibrary'; import { getMediaFile, waitForMediaLibraryToLoad } from './mediaLibrary';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk'; import type { ThunkDispatch } from 'redux-thunk';
@ -58,18 +56,9 @@ async function loadAsset(
dispatch(loadAssetRequest(resolvedPath)); dispatch(loadAssetRequest(resolvedPath));
// load asset url from backend // load asset url from backend
await waitForMediaLibraryToLoad(dispatch, getState()); await waitForMediaLibraryToLoad(dispatch, getState());
const file = selectMediaFileByPath(getState(), resolvedPath); const { url } = await getMediaFile(getState(), resolvedPath);
const asset = createAssetProxy({ path: resolvedPath, url });
let asset: AssetProxy; dispatch(addAsset(asset));
if (file) {
const url = await getMediaDisplayURL(dispatch, getState(), file);
asset = createAssetProxy({ path: resolvedPath, url: url || resolvedPath });
dispatch(addAsset(asset));
} else {
const { url } = await getMediaFile(getState(), resolvedPath);
asset = createAssetProxy({ path: resolvedPath, url });
dispatch(addAsset(asset));
}
dispatch(loadAssetSuccess(resolvedPath)); dispatch(loadAssetSuccess(resolvedPath));
return asset; return asset;
} catch (error: unknown) { } catch (error: unknown) {
@ -105,7 +94,8 @@ export function getAsset<F extends BaseField = UnknownField>(
path, path,
field as Field, field as Field,
); );
let { asset, isLoading, error } = state.medias[resolvedPath] || {};
const { asset, isLoading } = state.medias[resolvedPath] || {};
if (isLoading) { if (isLoading) {
return promiseCache[resolvedPath]; return promiseCache[resolvedPath];
} }
@ -116,23 +106,9 @@ export function getAsset<F extends BaseField = UnknownField>(
} }
const p = new Promise<AssetProxy>(resolve => { const p = new Promise<AssetProxy>(resolve => {
if (isAbsolutePath(resolvedPath)) { loadAsset(resolvedPath, dispatch, getState).then(asset => {
// asset path is a public url so we can just use it as is
asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset));
resolve(asset); resolve(asset);
} else { });
if (error) {
// on load error default back to original path
asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset));
resolve(asset);
} else {
loadAsset(resolvedPath, dispatch, getState).then(asset => {
resolve(asset);
});
}
}
}); });
promiseCache[resolvedPath] = p; promiseCache[resolvedPath] = p;

View File

@ -34,6 +34,7 @@ import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk'; import type { ThunkDispatch } from 'redux-thunk';
import type { import type {
BaseField, BaseField,
Collection,
DisplayURLState, DisplayURLState,
Field, Field,
ImplementationMediaFile, ImplementationMediaFile,
@ -83,16 +84,28 @@ export function openMediaLibrary<F extends BaseField = UnknownField>(
allowMultiple?: boolean; allowMultiple?: boolean;
replaceIndex?: number; replaceIndex?: number;
config?: Record<string, unknown>; config?: Record<string, unknown>;
collection?: Collection<F>;
field?: F; field?: F;
} = {}, } = {},
) { ) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => { return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState(); const state = getState();
const mediaLibrary = state.mediaLibrary.externalLibrary; const mediaLibrary = state.mediaLibrary.externalLibrary;
const { controlID, value, config = {}, allowMultiple, forImage, replaceIndex, field } = payload; const {
controlID,
value,
config = {},
allowMultiple,
forImage,
replaceIndex,
collection,
field,
} = payload;
if (mediaLibrary) { if (mediaLibrary) {
mediaLibrary.show({ id: controlID, value, config, allowMultiple, imagesOnly: forImage }); mediaLibrary.show({ id: controlID, value, config, allowMultiple, imagesOnly: forImage });
} }
dispatch( dispatch(
mediaLibraryOpened({ mediaLibraryOpened({
controlID, controlID,
@ -101,6 +114,7 @@ export function openMediaLibrary<F extends BaseField = UnknownField>(
allowMultiple, allowMultiple,
replaceIndex, replaceIndex,
config, config,
collection: collection as Collection,
field: field as Field, field: field as Field,
}), }),
); );
@ -406,6 +420,7 @@ function mediaLibraryOpened(payload: {
replaceIndex?: number; replaceIndex?: number;
allowMultiple?: boolean; allowMultiple?: boolean;
config?: Record<string, unknown>; config?: Record<string, unknown>;
collection?: Collection;
field?: Field; field?: Field;
}) { }) {
return { type: MEDIA_LIBRARY_OPEN, payload } as const; return { type: MEDIA_LIBRARY_OPEN, payload } as const;

View File

@ -14,8 +14,8 @@ import { currentBackend } from '@staticcms/core/backend';
import { colors, GlobalStyles } from '@staticcms/core/components/UI/styles'; import { colors, GlobalStyles } from '@staticcms/core/components/UI/styles';
import { history } from '@staticcms/core/routing/history'; import { history } from '@staticcms/core/routing/history';
import { getDefaultPath } from '../../lib/util/collection.util'; import { getDefaultPath } from '../../lib/util/collection.util';
import CollectionRoute from '../Collection/CollectionRoute'; import CollectionRoute from '../collection/CollectionRoute';
import EditorRoute from '../Editor/EditorRoute'; import EditorRoute from '../editor/EditorRoute';
import MediaLibrary from '../MediaLibrary/MediaLibrary'; import MediaLibrary from '../MediaLibrary/MediaLibrary';
import Page from '../page/Page'; import Page from '../page/Page';
import Snackbars from '../snackbar/Snackbars'; import Snackbars from '../snackbar/Snackbars';
@ -183,7 +183,7 @@ const App = ({
element={<EditorRoute collections={collections} newRecord />} element={<EditorRoute collections={collections} newRecord />}
/> />
<Route <Route
path="/collections/:name/entries/:slug" path="/collections/:name/entries/*"
element={<EditorRoute collections={collections} />} element={<EditorRoute collections={collections} />}
/> />
<Route <Route

View File

@ -63,6 +63,7 @@ const Entries = ({
<> <>
{'collection' in otherProps ? ( {'collection' in otherProps ? (
<EntryListing <EntryListing
key="collection-listing"
collection={otherProps.collection} collection={otherProps.collection}
entries={entries} entries={entries}
viewStyle={viewStyle} viewStyle={viewStyle}
@ -72,6 +73,7 @@ const Entries = ({
/> />
) : ( ) : (
<EntryListing <EntryListing
key="search-listing"
collections={otherProps.collections} collections={otherProps.collections}
entries={entries} entries={entries}
viewStyle={viewStyle} viewStyle={viewStyle}

View File

@ -1,22 +1,17 @@
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { import { loadEntries, traverseCollectionCursor } from '@staticcms/core/actions/entries';
loadEntries as loadEntriesAction,
traverseCollectionCursor as traverseCollectionCursorAction,
} from '@staticcms/core/actions/entries';
import { colors } from '@staticcms/core/components/UI/styles'; import { colors } from '@staticcms/core/components/UI/styles';
import useEntries from '@staticcms/core/lib/hooks/useEntries';
import useGroups from '@staticcms/core/lib/hooks/useGroups';
import { Cursor } from '@staticcms/core/lib/util'; import { Cursor } from '@staticcms/core/lib/util';
import { selectCollectionEntriesCursor } from '@staticcms/core/reducers/selectors/cursors'; import { selectCollectionEntriesCursor } from '@staticcms/core/reducers/selectors/cursors';
import { import { selectEntriesLoaded, selectIsFetching } from '@staticcms/core/reducers/selectors/entries';
selectEntries,
selectEntriesLoaded,
selectGroups,
selectIsFetching,
} from '@staticcms/core/reducers/selectors/entries';
import Entries from './Entries'; import Entries from './Entries';
import { useAppDispatch } from '@staticcms/core/store/hooks';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews'; import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '@staticcms/core/interface'; import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '@staticcms/core/interface';
@ -48,90 +43,6 @@ function getGroupTitle(group: GroupOfEntries, t: t) {
return `${label} ${value}`.trim(); return `${label} ${value}`.trim();
} }
function withGroups(
groups: GroupOfEntries[],
entries: Entry[],
EntriesToRender: ComponentType<EntriesToRenderProps>,
t: t,
) {
return groups.map(group => {
const title = getGroupTitle(group, t);
return (
<GroupContainer key={group.id} id={group.id}>
<GroupHeading>{title}</GroupHeading>
<EntriesToRender entries={getGroupEntries(entries, group.paths)} />
</GroupContainer>
);
});
}
interface EntriesToRenderProps {
entries: Entry[];
}
const EntriesCollection = ({
collection,
entries,
groups,
isFetching,
viewStyle,
cursor,
page,
traverseCollectionCursor,
t,
entriesLoaded,
readyToLoad,
loadEntries,
}: TranslatedProps<EntriesCollectionProps>) => {
const [prevReadyToLoad, setPrevReadyToLoad] = useState(false);
const [prevCollection, setPrevCollection] = useState(collection);
useEffect(() => {
if (
collection &&
!entriesLoaded &&
readyToLoad &&
(!prevReadyToLoad || prevCollection !== collection)
) {
loadEntries(collection);
}
setPrevReadyToLoad(readyToLoad);
setPrevCollection(collection);
}, [collection, entriesLoaded, loadEntries, prevCollection, prevReadyToLoad, readyToLoad]);
const handleCursorActions = useCallback(
(action: string) => {
traverseCollectionCursor(collection, action);
},
[collection, traverseCollectionCursor],
);
const EntriesToRender = useCallback(
({ entries }: EntriesToRenderProps) => {
return (
<Entries
collection={collection}
entries={entries}
isFetching={isFetching}
collectionName={collection.label}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={handleCursorActions}
page={page}
/>
);
},
[collection, cursor, handleCursorActions, isFetching, page, viewStyle],
);
if (groups && groups.length > 0) {
return <>{withGroups(groups, entries, EntriesToRender, t)}</>;
}
return <EntriesToRender entries={entries} />;
};
export function filterNestedEntries(path: string, collectionFolder: string, entries: Entry[]) { export function filterNestedEntries(path: string, collectionFolder: string, entries: Entry[]) {
const filtered = entries.filter(e => { const filtered = entries.filter(e => {
const entryPath = e.path.slice(collectionFolder.length + 1); const entryPath = e.path.slice(collectionFolder.length + 1);
@ -152,6 +63,94 @@ export function filterNestedEntries(path: string, collectionFolder: string, entr
return filtered; return filtered;
} }
const EntriesCollection = ({
collection,
filterTerm,
isFetching,
viewStyle,
cursor,
page,
t,
entriesLoaded,
readyToLoad,
}: TranslatedProps<EntriesCollectionProps>) => {
const dispatch = useAppDispatch();
const [prevReadyToLoad, setPrevReadyToLoad] = useState(false);
const [prevCollection, setPrevCollection] = useState(collection);
const groups = useGroups(collection.name);
const entries = useEntries(collection);
const filteredEntries = useMemo(() => {
if ('nested' in collection) {
const collectionFolder = collection.folder ?? '';
return filterNestedEntries(filterTerm || '', collectionFolder, entries);
}
return entries;
}, [collection, entries, filterTerm]);
useEffect(() => {
if (
collection &&
!entriesLoaded &&
readyToLoad &&
(!prevReadyToLoad || prevCollection !== collection)
) {
dispatch(loadEntries(collection));
}
setPrevReadyToLoad(readyToLoad);
setPrevCollection(collection);
}, [collection, dispatch, entriesLoaded, prevCollection, prevReadyToLoad, readyToLoad]);
const handleCursorActions = useCallback(
(action: string) => {
dispatch(traverseCollectionCursor(collection, action));
},
[collection, dispatch],
);
if (groups && groups.length > 0) {
<>
{groups.map(group => {
const title = getGroupTitle(group, t);
return (
<GroupContainer key={group.id} id={group.id}>
<GroupHeading>{title}</GroupHeading>
<Entries
collection={collection}
entries={getGroupEntries(filteredEntries, group.paths)}
isFetching={isFetching}
collectionName={collection.label}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={handleCursorActions}
page={page}
/>
</GroupContainer>
);
})}
</>;
}
return (
<Entries
key="entries-without-group"
collection={collection}
entries={filteredEntries}
isFetching={isFetching}
collectionName={collection.label}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={handleCursorActions}
page={page}
/>
);
};
interface EntriesCollectionOwnProps { interface EntriesCollectionOwnProps {
collection: Collection; collection: Collection;
viewStyle: CollectionViewStyle; viewStyle: CollectionViewStyle;
@ -163,27 +162,16 @@ function mapStateToProps(state: RootState, ownProps: EntriesCollectionOwnProps)
const { collection, viewStyle, filterTerm } = ownProps; const { collection, viewStyle, filterTerm } = ownProps;
const page = state.entries.pages[collection.name]?.page; const page = state.entries.pages[collection.name]?.page;
let entries = selectEntries(state, collection);
const groups = selectGroups(state, collection);
if ('nested' in collection) {
const collectionFolder = collection.folder ?? '';
entries = filterNestedEntries(filterTerm || '', collectionFolder, entries);
}
const entriesLoaded = selectEntriesLoaded(state, collection.name); const entriesLoaded = selectEntriesLoaded(state, collection.name);
const isFetching = selectIsFetching(state, collection.name); const isFetching = selectIsFetching(state, collection.name);
const rawCursor = selectCollectionEntriesCursor(state, collection.name); const rawCursor = selectCollectionEntriesCursor(state, collection.name);
const cursor = Cursor.create(rawCursor).clearData(); const cursor = Cursor.create(rawCursor).clearData();
return { ...ownProps, page, entries, groups, entriesLoaded, isFetching, viewStyle, cursor }; return { ...ownProps, page, filterTerm, entriesLoaded, isFetching, viewStyle, cursor };
} }
const mapDispatchToProps = { const mapDispatchToProps = {};
loadEntries: loadEntriesAction,
traverseCollectionCursor: traverseCollectionCursorAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps); const connector = connect(mapStateToProps, mapDispatchToProps);
export type EntriesCollectionProps = ConnectedProps<typeof connector>; export type EntriesCollectionProps = ConnectedProps<typeof connector>;

View File

@ -4,10 +4,8 @@ import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia'; import CardMedia from '@mui/material/CardMedia';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { getAsset as getAssetAction } from '@staticcms/core/actions/media';
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '@staticcms/core/constants/collectionViews'; import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '@staticcms/core/constants/collectionViews';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset'; import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import { getPreviewCard } from '@staticcms/core/lib/registry'; import { getPreviewCard } from '@staticcms/core/lib/registry';
@ -17,28 +15,58 @@ import {
selectTemplateName, selectTemplateName,
} from '@staticcms/core/lib/util/collection.util'; } from '@staticcms/core/lib/util/collection.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config'; import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectIsLoadingAsset } from '@staticcms/core/reducers/selectors/medias';
import { useAppSelector } from '@staticcms/core/store/hooks'; import { useAppSelector } from '@staticcms/core/store/hooks';
import useWidgetsFor from '../../common/widget/useWidgetsFor'; import useWidgetsFor from '../../common/widget/useWidgetsFor';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews'; import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
import type { Collection, Entry, Field } from '@staticcms/core/interface'; import type { Collection, Entry, FileOrImageField, MediaField } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { ConnectedProps } from 'react-redux'; export interface EntryCardProps {
entry: Entry;
imageFieldName?: string | null | undefined;
collection: Collection;
collectionLabel?: string;
viewStyle?: CollectionViewStyle;
}
const EntryCard = ({ const EntryCard = ({
collection, collection,
entry, entry,
path,
image,
imageField,
collectionLabel, collectionLabel,
viewStyle = VIEW_STYLE_LIST, viewStyle = VIEW_STYLE_LIST,
}: NestedCollectionProps) => { imageFieldName,
}: EntryCardProps) => {
const entryData = entry.data;
const path = useMemo(
() => `/collections/${collection.name}/entries/${entry.slug}`,
[collection.name, entry.slug],
);
const imageField = useMemo(
() =>
'fields' in collection
? (collection.fields?.find(
f => f.name === imageFieldName && f.widget === 'image',
) as FileOrImageField)
: undefined,
[collection, imageFieldName],
);
const image = useMemo(() => {
let i = imageFieldName ? (entryData?.[imageFieldName] as string | undefined) : undefined;
if (i) {
i = encodeURI(i.trim());
}
return i;
}, [entryData, imageFieldName]);
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]); const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
const fields = selectFields(collection, entry.slug); const fields = selectFields(collection, entry.slug);
const imageUrl = useMediaAsset(image, collection, imageField, entry); const imageUrl = useMediaAsset(image, collection as Collection<MediaField>, imageField, entry);
const config = useAppSelector(selectConfig); const config = useAppSelector(selectConfig);
@ -97,51 +125,4 @@ const EntryCard = ({
); );
}; };
interface EntryCardOwnProps { export default EntryCard;
entry: Entry;
inferredFields: {
titleField?: string | null | undefined;
descriptionField?: string | null | undefined;
imageField?: string | null | undefined;
remainingFields?: Field[] | undefined;
};
collection: Collection;
imageField?: Field;
collectionLabel?: string;
viewStyle?: CollectionViewStyle;
}
function mapStateToProps(state: RootState, ownProps: EntryCardOwnProps) {
const { entry, inferredFields, collection } = ownProps;
const entryData = entry.data;
let image = inferredFields.imageField
? (entryData?.[inferredFields.imageField] as string | undefined)
: undefined;
if (image) {
image = encodeURI(image.trim());
}
const isLoadingAsset = selectIsLoadingAsset(state);
return {
...ownProps,
path: `/collections/${collection.name}/entries/${entry.slug}`,
image,
imageField:
'fields' in collection
? collection.fields?.find(f => f.name === inferredFields.imageField && f.widget === 'image')
: undefined,
isLoadingAsset,
};
}
const mapDispatchToProps = {
getAsset: getAssetAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type NestedCollectionProps = ConnectedProps<typeof connector>;
export default connector(EntryCard);

View File

@ -8,7 +8,7 @@ import { selectFields, selectInferredField } from '@staticcms/core/lib/util/coll
import EntryCard from './EntryCard'; import EntryCard from './EntryCard';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews'; import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
import type { Field, Collection, Collections, Entry } from '@staticcms/core/interface'; import type { Collection, Collections, Entry, Field } from '@staticcms/core/interface';
import type Cursor from '@staticcms/core/lib/util/Cursor'; import type Cursor from '@staticcms/core/lib/util/Cursor';
interface CardsGridProps { interface CardsGridProps {
@ -100,19 +100,19 @@ const EntryListing = ({
const renderedCards = useMemo(() => { const renderedCards = useMemo(() => {
if ('collection' in otherProps) { if ('collection' in otherProps) {
const inferredFields = inferFields(otherProps.collection); const inferredFields = inferFields(otherProps.collection);
return entries.map((entry, idx) => ( return entries.map(entry => (
<EntryCard <EntryCard
collection={otherProps.collection} collection={otherProps.collection}
inferredFields={inferredFields} imageFieldName={inferredFields.imageField}
viewStyle={viewStyle} viewStyle={viewStyle}
entry={entry} entry={entry}
key={idx} key={entry.slug}
/> />
)); ));
} }
const isSingleCollectionInList = Object.keys(otherProps.collections).length === 1; const isSingleCollectionInList = Object.keys(otherProps.collections).length === 1;
return entries.map((entry, idx) => { return entries.map(entry => {
const collectionName = entry.collection; const collectionName = entry.collection;
const collection = Object.values(otherProps.collections).find( const collection = Object.values(otherProps.collections).find(
coll => coll.name === collectionName, coll => coll.name === collectionName,
@ -123,9 +123,9 @@ const EntryListing = ({
<EntryCard <EntryCard
collection={collection} collection={collection}
entry={entry} entry={entry}
inferredFields={inferredFields} imageFieldName={inferredFields.imageField}
collectionLabel={collectionLabel} collectionLabel={collectionLabel}
key={idx} key={entry.slug}
/> />
) : null; ) : null;
}); });

View File

@ -3,18 +3,15 @@ import { styled } from '@mui/material/styles';
import sortBy from 'lodash/sortBy'; import sortBy from 'lodash/sortBy';
import { dirname, sep } from 'path'; import { dirname, sep } from 'path';
import React, { Fragment, useCallback, useEffect, useState } from 'react'; import React, { Fragment, useCallback, useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { colors, components } from '@staticcms/core/components/UI/styles'; import { colors, components } from '@staticcms/core/components/UI/styles';
import { transientOptions } from '@staticcms/core/lib'; import { transientOptions } from '@staticcms/core/lib';
import useEntries from '@staticcms/core/lib/hooks/useEntries';
import { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util'; import { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util';
import { stringTemplate } from '@staticcms/core/lib/widgets'; import { stringTemplate } from '@staticcms/core/lib/widgets';
import { selectEntries } from '@staticcms/core/reducers/selectors/entries';
import type { Collection, Entry } from '@staticcms/core/interface'; import type { Collection, Entry } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { ConnectedProps } from 'react-redux';
const { addFileTemplateFields } = stringTemplate; const { addFileTemplateFields } = stringTemplate;
@ -271,7 +268,14 @@ export function updateNode(
return updater([...treeData]); return updater([...treeData]);
} }
const NestedCollection = ({ collection, entries, filterTerm }: NestedCollectionProps) => { export interface NestedCollectionProps {
collection: Collection;
filterTerm: string;
}
const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) => {
const entries = useEntries(collection);
const [treeData, setTreeData] = useState<TreeNodeData[]>(getTreeData(collection, entries)); const [treeData, setTreeData] = useState<TreeNodeData[]>(getTreeData(collection, entries));
const [selected, setSelected] = useState<TreeNodeData | null>(null); const [selected, setSelected] = useState<TreeNodeData | null>(null);
const [useFilter, setUseFilter] = useState(true); const [useFilter, setUseFilter] = useState(true);
@ -337,18 +341,4 @@ const NestedCollection = ({ collection, entries, filterTerm }: NestedCollectionP
return <TreeNode collection={collection} treeData={treeData} onToggle={onToggle} />; return <TreeNode collection={collection} treeData={treeData} onToggle={onToggle} />;
}; };
interface NestedCollectionOwnProps { export default NestedCollection;
collection: Collection;
filterTerm: string;
}
function mapStateToProps(state: RootState, ownProps: NestedCollectionOwnProps) {
const { collection } = ownProps;
const entries = selectEntries(state, collection) ?? [];
return { ...ownProps, entries };
}
const connector = connect(mapStateToProps, {});
export type NestedCollectionProps = ConnectedProps<typeof connector>;
export default connector(NestedCollection);

View File

@ -12,7 +12,9 @@ interface EditorRouteProps {
} }
const EditorRoute = ({ newRecord = false, collections }: EditorRouteProps) => { const EditorRoute = ({ newRecord = false, collections }: EditorRouteProps) => {
const { name, slug } = useParams(); const { name, ...params } = useParams();
const slug = params['*'];
const shouldRedirect = useMemo(() => { const shouldRedirect = useMemo(() => {
if (!name) { if (!name) {
return false; return false;

View File

@ -1,6 +1,5 @@
import fuzzy from 'fuzzy'; import fuzzy from 'fuzzy';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { import {
@ -12,12 +11,13 @@ import {
persistMedia as persistMediaAction, persistMedia as persistMediaAction,
} from '@staticcms/core/actions/mediaLibrary'; } from '@staticcms/core/actions/mediaLibrary';
import { fileExtension } from '@staticcms/core/lib/util'; import { fileExtension } from '@staticcms/core/lib/util';
import MediaLibraryCloseEvent from '@staticcms/core/lib/util/events/MediaLibraryCloseEvent';
import { selectMediaFiles } from '@staticcms/core/reducers/selectors/mediaLibrary'; import { selectMediaFiles } from '@staticcms/core/reducers/selectors/mediaLibrary';
import alert from '../UI/Alert'; import alert from '../UI/Alert';
import confirm from '../UI/Confirm'; import confirm from '../UI/Confirm';
import MediaLibraryModal from './MediaLibraryModal'; import MediaLibraryModal from './MediaLibraryModal';
import type { MediaFile, TranslatedProps } from '@staticcms/core/interface'; import type { MediaFile } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store'; import type { RootState } from '@staticcms/core/store';
import type { ChangeEvent, KeyboardEvent } from 'react'; import type { ChangeEvent, KeyboardEvent } from 'react';
import type { ConnectedProps } from 'react-redux'; import type { ConnectedProps } from 'react-redux';
@ -53,7 +53,7 @@ const MediaLibrary = ({
isDeleting, isDeleting,
hasNextPage, hasNextPage,
isPaginating, isPaginating,
config, config: mediaConfig,
loadMedia, loadMedia,
dynamicSearchQuery, dynamicSearchQuery,
page, page,
@ -61,31 +61,28 @@ const MediaLibrary = ({
deleteMedia, deleteMedia,
insertMedia, insertMedia,
closeMediaLibrary, closeMediaLibrary,
collection,
field, field,
t, }: MediaLibraryProps) => {
}: TranslatedProps<MediaLibraryProps>) => {
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null); const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
const [query, setQuery] = useState<string | undefined>(undefined); const [query, setQuery] = useState<string | undefined>(undefined);
const [prevIsVisible, setPrevIsVisible] = useState(false); const [prevIsVisible, setPrevIsVisible] = useState(false);
useEffect(() => { useEffect(() => {
loadMedia(); loadMedia({});
}, [loadMedia]); }, [loadMedia]);
useEffect(() => { useEffect(() => {
if (!prevIsVisible && isVisible) { if (!prevIsVisible && isVisible) {
setSelectedFile(null); setSelectedFile(null);
setQuery(''); setQuery('');
loadMedia();
} else if (prevIsVisible && !isVisible) {
window.dispatchEvent(new MediaLibraryCloseEvent());
} }
setPrevIsVisible(isVisible); setPrevIsVisible(isVisible);
}, [isVisible, prevIsVisible]);
useEffect(() => {
if (!prevIsVisible && isVisible) {
loadMedia();
}
}, [isVisible, loadMedia, prevIsVisible]); }, [isVisible, loadMedia, prevIsVisible]);
const loadDisplayURL = useCallback( const loadDisplayURL = useCallback(
@ -155,7 +152,7 @@ const MediaLibrary = ({
[selectedFile?.key], [selectedFile?.key],
); );
const scrollContainerRef = useRef<HTMLDivElement>(); const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const scrollToTop = () => { const scrollToTop = () => {
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0; scrollContainerRef.current.scrollTop = 0;
@ -189,7 +186,8 @@ const MediaLibrary = ({
event.preventDefault(); event.preventDefault();
const files = [...Array.from(fileList)]; const files = [...Array.from(fileList)];
const file = files[0]; const file = files[0];
const maxFileSize = typeof config.max_file_size === 'number' ? config.max_file_size : 512000; const maxFileSize =
typeof mediaConfig.max_file_size === 'number' ? mediaConfig.max_file_size : 512000;
if (maxFileSize && file.size > maxFileSize) { if (maxFileSize && file.size > maxFileSize) {
alert({ alert({
@ -213,7 +211,7 @@ const MediaLibrary = ({
event.target.value = ''; event.target.value = '';
} }
}, },
[config.max_file_size, field, persistMedia], [mediaConfig.max_file_size, field, persistMedia],
); );
/** /**
@ -310,7 +308,7 @@ const MediaLibrary = ({
/** /**
* Filters files that do not match the query. Not used for dynamic search. * Filters files that do not match the query. Not used for dynamic search.
*/ */
const queryFilter = useCallback((query: string, files: { name: string }[]) => { const queryFilter = useCallback((query: string, files: MediaFile[]): MediaFile[] => {
/** /**
* Because file names don't have spaces, typing a space eliminates all * Because file names don't have spaces, typing a space eliminates all
* potential matches, so we strip them all out internally before running the * potential matches, so we strip them all out internally before running the
@ -318,11 +316,10 @@ const MediaLibrary = ({
*/ */
const strippedQuery = query.replace(/ /g, ''); const strippedQuery = query.replace(/ /g, '');
const matches = fuzzy.filter(strippedQuery, files, { extract: file => file.name }); const matches = fuzzy.filter(strippedQuery, files, { extract: file => file.name });
const matchFiles = matches.map((match, queryIndex) => { return matches.map((match, queryIndex) => {
const file = files[match.index]; const file = files[match.index];
return { ...file, queryIndex }; return { ...file, queryIndex };
}); }) as MediaFile[];
return matchFiles;
}, []); }, []);
return ( return (
@ -339,7 +336,7 @@ const MediaLibrary = ({
hasNextPage={hasNextPage} hasNextPage={hasNextPage}
isPaginating={isPaginating} isPaginating={isPaginating}
query={query} query={query}
selectedFile={selectedFile} selectedFile={selectedFile ?? undefined}
handleFilter={filterImages} handleFilter={filterImages}
handleQuery={queryFilter} handleQuery={queryFilter}
toTableData={toTableData} toTableData={toTableData}
@ -350,12 +347,13 @@ const MediaLibrary = ({
handleDelete={handleDelete} handleDelete={handleDelete}
handleInsert={handleInsert} handleInsert={handleInsert}
handleDownload={handleDownload} handleDownload={handleDownload}
setScrollContainerRef={scrollContainerRef} scrollContainerRef={scrollContainerRef}
handleAssetClick={handleAssetClick} handleAssetClick={handleAssetClick}
handleLoadMore={handleLoadMore} handleLoadMore={handleLoadMore}
displayURLs={displayURLs} displayURLs={displayURLs}
loadDisplayURL={loadDisplayURL} loadDisplayURL={loadDisplayURL}
t={t} collection={collection}
field={field}
/> />
); );
}; };
@ -379,6 +377,7 @@ function mapStateToProps(state: RootState) {
page: mediaLibrary.page, page: mediaLibrary.page,
hasNextPage: mediaLibrary.hasNextPage, hasNextPage: mediaLibrary.hasNextPage,
isPaginating: mediaLibrary.isPaginating, isPaginating: mediaLibrary.isPaginating,
collection: mediaLibrary.collection,
field, field,
}; };
return { ...mediaLibraryProps }; return { ...mediaLibraryProps };
@ -396,4 +395,4 @@ const mapDispatchToProps = {
const connector = connect(mapStateToProps, mapDispatchToProps); const connector = connect(mapStateToProps, mapDispatchToProps);
export type MediaLibraryProps = ConnectedProps<typeof connector>; export type MediaLibraryProps = ConnectedProps<typeof connector>;
export default connector(translate()(MediaLibrary)); export default connector(MediaLibrary);

View File

@ -4,8 +4,11 @@ import React, { useEffect } from 'react';
import { borders, colors, effects, lengths, shadows } from '@staticcms/core/components/UI/styles'; import { borders, colors, effects, lengths, shadows } from '@staticcms/core/components/UI/styles';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset'; import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import transientOptions from '@staticcms/core/lib/util/transientOptions'; import transientOptions from '@staticcms/core/lib/util/transientOptions';
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import { useAppSelector } from '@staticcms/core/store/hooks';
import type { MediaLibraryDisplayURL } from '@staticcms/core/reducers/mediaLibrary'; import type { MediaLibraryDisplayURL } from '@staticcms/core/reducers/mediaLibrary';
import type { Field, Collection } from '@staticcms/core/interface';
const IMAGE_HEIGHT = 160; const IMAGE_HEIGHT = 160;
@ -89,6 +92,8 @@ interface MediaLibraryCardProps {
isViewableImage: boolean; isViewableImage: boolean;
loadDisplayURL: () => void; loadDisplayURL: () => void;
isDraft?: boolean; isDraft?: boolean;
collection?: Collection;
field?: Field;
} }
const MediaLibraryCard = ({ const MediaLibraryCard = ({
@ -103,9 +108,12 @@ const MediaLibraryCard = ({
type, type,
isViewableImage, isViewableImage,
isDraft, isDraft,
collection,
field,
loadDisplayURL, loadDisplayURL,
}: MediaLibraryCardProps) => { }: MediaLibraryCardProps) => {
const url = useMediaAsset(displayURL.url); const entry = useAppSelector(selectEditingDraft);
const url = useMediaAsset(displayURL.url, collection, field, entry);
useEffect(() => { useEffect(() => {
if (!displayURL.url) { if (!displayURL.url) {

View File

@ -6,12 +6,12 @@ import { FixedSizeGrid as Grid } from 'react-window';
import MediaLibraryCard from './MediaLibraryCard'; import MediaLibraryCard from './MediaLibraryCard';
import type { GridChildComponentProps } from 'react-window'; import type { Collection, Field, MediaFile } from '@staticcms/core/interface';
import type { MediaFile } from '@staticcms/core/interface';
import type { import type {
MediaLibraryDisplayURL, MediaLibraryDisplayURL,
MediaLibraryState, MediaLibraryState,
} from '@staticcms/core/reducers/mediaLibrary'; } from '@staticcms/core/reducers/mediaLibrary';
import type { GridChildComponentProps } from 'react-window';
export interface MediaLibraryCardItem { export interface MediaLibraryCardItem {
displayURL?: MediaLibraryDisplayURL; displayURL?: MediaLibraryDisplayURL;
@ -25,7 +25,7 @@ export interface MediaLibraryCardItem {
} }
export interface MediaLibraryCardGridProps { export interface MediaLibraryCardGridProps {
setScrollContainerRef: () => void; scrollContainerRef: React.MutableRefObject<HTMLDivElement | null>;
mediaItems: MediaFile[]; mediaItems: MediaFile[];
isSelectedFile: (file: MediaFile) => boolean; isSelectedFile: (file: MediaFile) => boolean;
onAssetClick: (asset: MediaFile) => void; onAssetClick: (asset: MediaFile) => void;
@ -39,6 +39,8 @@ export interface MediaLibraryCardGridProps {
cardMargin: string; cardMargin: string;
loadDisplayURL: (asset: MediaFile) => void; loadDisplayURL: (asset: MediaFile) => void;
displayURLs: MediaLibraryState['displayURLs']; displayURLs: MediaLibraryState['displayURLs'];
collection?: Collection;
field?: Field;
} }
export type CardGridItemData = MediaLibraryCardGridProps & { export type CardGridItemData = MediaLibraryCardGridProps & {
@ -61,6 +63,8 @@ const CardWrapper = ({
loadDisplayURL, loadDisplayURL,
columnCount, columnCount,
gutter, gutter,
collection,
field,
}, },
}: GridChildComponentProps<CardGridItemData>) => { }: GridChildComponentProps<CardGridItemData>) => {
const index = rowIndex * columnCount + columnIndex; const index = rowIndex * columnCount + columnIndex;
@ -93,6 +97,8 @@ const CardWrapper = ({
loadDisplayURL={() => loadDisplayURL(file)} loadDisplayURL={() => loadDisplayURL(file)}
type={file.type} type={file.type}
isViewableImage={file.isViewableImage ?? false} isViewableImage={file.isViewableImage ?? false}
collection={collection}
field={field}
/> />
</div> </div>
); );
@ -126,7 +132,7 @@ const VirtualizedGrid = (props: MediaLibraryCardGridProps) => {
cardHeight: inputCardHeight, cardHeight: inputCardHeight,
cardMargin, cardMargin,
mediaItems, mediaItems,
setScrollContainerRef, scrollContainerRef,
} = props; } = props;
return ( return (
@ -141,7 +147,7 @@ const VirtualizedGrid = (props: MediaLibraryCardGridProps) => {
const rowCount = Math.ceil(mediaItems.length / columnCount); const rowCount = Math.ceil(mediaItems.length / columnCount);
return ( return (
<StyledCardGridContainer $width={width} $height={height} ref={setScrollContainerRef}> <StyledCardGridContainer $width={width} $height={height} ref={scrollContainerRef}>
<Grid <Grid
columnCount={columnCount} columnCount={columnCount}
columnWidth={columnWidth} columnWidth={columnWidth}
@ -168,7 +174,7 @@ const VirtualizedGrid = (props: MediaLibraryCardGridProps) => {
}; };
const PaginatedGrid = ({ const PaginatedGrid = ({
setScrollContainerRef, scrollContainerRef,
mediaItems, mediaItems,
isSelectedFile, isSelectedFile,
onAssetClick, onAssetClick,
@ -182,9 +188,11 @@ const PaginatedGrid = ({
onLoadMore, onLoadMore,
isPaginating, isPaginating,
paginatingMessage, paginatingMessage,
collection,
field,
}: MediaLibraryCardGridProps) => { }: MediaLibraryCardGridProps) => {
return ( return (
<StyledCardGridContainer ref={setScrollContainerRef}> <StyledCardGridContainer ref={scrollContainerRef}>
<CardGrid> <CardGrid>
{mediaItems.map(file => ( {mediaItems.map(file => (
<MediaLibraryCard <MediaLibraryCard
@ -201,6 +209,8 @@ const PaginatedGrid = ({
loadDisplayURL={() => loadDisplayURL(file)} loadDisplayURL={() => loadDisplayURL(file)}
type={file.type} type={file.type}
isViewableImage={file.isViewableImage ?? false} isViewableImage={file.isViewableImage ?? false}
collection={collection}
field={field}
/> />
))} ))}
{!canLoadMore ? null : <Waypoint onEnter={onLoadMore} />} {!canLoadMore ? null : <Waypoint onEnter={onLoadMore} />}

View File

@ -11,9 +11,9 @@ import EmptyMessage from './EmptyMessage';
import MediaLibraryCardGrid from './MediaLibraryCardGrid'; import MediaLibraryCardGrid from './MediaLibraryCardGrid';
import MediaLibraryTop from './MediaLibraryTop'; import MediaLibraryTop from './MediaLibraryTop';
import type { ChangeEvent, ChangeEventHandler, KeyboardEventHandler } from 'react'; import type { Collection, Field, MediaFile, TranslatedProps } from '@staticcms/core/interface';
import type { MediaFile, TranslatedProps } from '@staticcms/core/interface';
import type { MediaLibraryState } from '@staticcms/core/reducers/mediaLibrary'; import type { MediaLibraryState } from '@staticcms/core/reducers/mediaLibrary';
import type { ChangeEvent, ChangeEventHandler, FC, KeyboardEventHandler } from 'react';
const StyledFab = styled(Fab)` const StyledFab = styled(Fab)`
position: absolute; position: absolute;
@ -95,11 +95,13 @@ interface MediaLibraryModalProps {
handleDelete: () => void; handleDelete: () => void;
handleInsert: () => void; handleInsert: () => void;
handleDownload: () => void; handleDownload: () => void;
setScrollContainerRef: () => void; scrollContainerRef: React.MutableRefObject<HTMLDivElement | null>;
handleAssetClick: (asset: MediaFile) => void; handleAssetClick: (asset: MediaFile) => void;
handleLoadMore: () => void; handleLoadMore: () => void;
loadDisplayURL: (file: MediaFile) => void; loadDisplayURL: (file: MediaFile) => void;
displayURLs: MediaLibraryState['displayURLs']; displayURLs: MediaLibraryState['displayURLs'];
collection?: Collection;
field?: Field;
} }
const MediaLibraryModal = ({ const MediaLibraryModal = ({
@ -126,11 +128,13 @@ const MediaLibraryModal = ({
handleDelete, handleDelete,
handleInsert, handleInsert,
handleDownload, handleDownload,
setScrollContainerRef, scrollContainerRef,
handleAssetClick, handleAssetClick,
handleLoadMore, handleLoadMore,
loadDisplayURL, loadDisplayURL,
displayURLs, displayURLs,
collection,
field,
t, t,
}: TranslatedProps<MediaLibraryModalProps>) => { }: TranslatedProps<MediaLibraryModalProps>) => {
const filteredFiles = forImage ? handleFilter(files) : files; const filteredFiles = forImage ? handleFilter(files) : files;
@ -177,7 +181,7 @@ const MediaLibraryModal = ({
<DialogContent> <DialogContent>
{!shouldShowEmptyMessage ? null : <EmptyMessage content={emptyMessage} />} {!shouldShowEmptyMessage ? null : <EmptyMessage content={emptyMessage} />}
<MediaLibraryCardGrid <MediaLibraryCardGrid
setScrollContainerRef={setScrollContainerRef} scrollContainerRef={scrollContainerRef}
mediaItems={tableData} mediaItems={tableData}
isSelectedFile={file => selectedFile?.key === file.key} isSelectedFile={file => selectedFile?.key === file.key}
onAssetClick={handleAssetClick} onAssetClick={handleAssetClick}
@ -191,10 +195,12 @@ const MediaLibraryModal = ({
cardMargin={cardMargin} cardMargin={cardMargin}
loadDisplayURL={loadDisplayURL} loadDisplayURL={loadDisplayURL}
displayURLs={displayURLs} displayURLs={displayURLs}
collection={collection}
field={field}
/> />
</DialogContent> </DialogContent>
</StyledModal> </StyledModal>
); );
}; };
export default translate()(MediaLibraryModal); export default translate()(MediaLibraryModal) as FC<MediaLibraryModalProps>;

View File

@ -0,0 +1,35 @@
import React from 'react';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import { useAppSelector } from '@staticcms/core/store/hooks';
import type { Collection, MediaField } from '@staticcms/core/interface';
export interface ImageProps<F extends MediaField> {
src?: string;
alt?: string;
collection?: Collection<F>;
field?: F;
}
const Image = <F extends MediaField>({ src, alt, collection, field }: ImageProps<F>) => {
const entry = useAppSelector(selectEditingDraft);
const assetSource = useMediaAsset(src, collection, field, entry);
return <img key="image" role="presentation" src={assetSource} alt={alt} />;
};
export const withMdxImage = <F extends MediaField>({
collection,
field,
}: Omit<ImageProps<F>, 'src' | 'alt'>) => {
const MdxImage = ({ src, alt }: Pick<ImageProps<F>, 'src' | 'alt'>) => (
<Image src={src} alt={alt} collection={collection} field={field} />
);
return MdxImage;
};
export default Image;

View File

@ -6,7 +6,7 @@ import { useParams } from 'react-router-dom';
import { getAdditionalLink } from '@staticcms/core/lib/registry'; import { getAdditionalLink } from '@staticcms/core/lib/registry';
import MainView from '../App/MainView'; import MainView from '../App/MainView';
import Sidebar from '../Collection/Sidebar'; import Sidebar from '../collection/Sidebar';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux'; import type { ConnectedProps } from 'react-redux';

View File

@ -535,6 +535,12 @@ export interface BaseField {
widget: string; widget: string;
} }
export interface MediaField extends BaseField {
media_library?: MediaLibrary;
media_folder?: string;
public_folder?: string;
}
export interface BooleanField extends BaseField { export interface BooleanField extends BaseField {
widget: 'boolean'; widget: 'boolean';
default?: boolean; default?: boolean;
@ -572,13 +578,9 @@ export interface DateTimeField extends BaseField {
picker_utc?: boolean; picker_utc?: boolean;
} }
export interface FileOrImageField extends BaseField { export interface FileOrImageField extends MediaField {
widget: 'file' | 'image'; widget: 'file' | 'image';
default?: string; default?: string;
media_library?: MediaLibrary;
media_folder?: string;
public_folder?: string;
} }
export interface ObjectField<EF extends BaseField = UnknownField> extends BaseField { export interface ObjectField<EF extends BaseField = UnknownField> extends BaseField {
@ -614,13 +616,9 @@ export interface MapField extends BaseField {
height?: string; height?: string;
} }
export interface MarkdownField extends BaseField { export interface MarkdownField extends MediaField {
widget: 'markdown'; widget: 'markdown';
default?: string; default?: string;
media_library?: MediaLibrary;
media_folder?: string;
public_folder?: string;
} }
export interface NumberField extends BaseField { export interface NumberField extends BaseField {

View File

@ -0,0 +1,51 @@
import get from 'lodash/get';
import orderBy from 'lodash/orderBy';
import { useMemo } from 'react';
import { SORT_DIRECTION_ASCENDING } from '@staticcms/core/constants';
import { selectEntriesSortField } from '@staticcms/core/reducers/selectors/entries';
import { useAppSelector } from '@staticcms/core/store/hooks';
import { selectSortDataPath } from '../util/sort.util';
import useFilters from './useFilters';
import usePublishedEntries from './usePublishedEntries';
import type { Collection } from '@staticcms/core/interface';
export default function useEntries(collection: Collection) {
const entries = usePublishedEntries(collection.name);
const entriesSortFieldSelector = useMemo(
() => selectEntriesSortField(collection.name),
[collection.name],
);
const sortField = useAppSelector(entriesSortFieldSelector);
const filters = useFilters(collection.name);
return useMemo(() => {
let finalEntries = [...entries];
if (sortField) {
const key = selectSortDataPath(collection, sortField.key);
const order = sortField.direction === SORT_DIRECTION_ASCENDING ? 'asc' : 'desc';
finalEntries = orderBy(finalEntries, key, order);
}
if (filters && filters.length > 0) {
finalEntries = finalEntries.filter(e => {
const allMatched = filters.every(f => {
const pattern = f.pattern;
const field = f.field;
const data = e!.data || {};
const toMatch = get(data, field);
const matched =
toMatch !== undefined && new RegExp(String(pattern)).test(String(toMatch));
return matched;
});
return allMatched;
});
}
return finalEntries;
}, [collection, entries, filters, sortField]);
}

View File

@ -0,0 +1,16 @@
import { useMemo } from 'react';
import { selectEntriesSortField } from '@staticcms/core/reducers/selectors/entries';
import { useAppSelector } from '@staticcms/core/store/hooks';
export default function useFilters(collectionName: string) {
const entriesSortFieldSelector = useMemo(
() => selectEntriesSortField(collectionName),
[collectionName],
);
const filters = useAppSelector(entriesSortFieldSelector);
return useMemo(() => {
return Object.values(filters ?? {}).filter(v => v?.active === true) || [];
}, [filters]);
}

View File

@ -0,0 +1,41 @@
import groupBy from 'lodash/groupBy';
import { useMemo } from 'react';
import { getGroup, selectEntriesGroupField } from '@staticcms/core/reducers/selectors/entries';
import { useAppSelector } from '@staticcms/core/store/hooks';
import usePublishedEntries from './usePublishedEntries';
import type { GroupOfEntries } from '@staticcms/core/interface';
export default function useGroups(collectionName: string) {
const entries = usePublishedEntries(collectionName);
const entriesGroupFieldSelector = useMemo(
() => selectEntriesGroupField(collectionName),
[collectionName],
);
const selectedGroup = useAppSelector(entriesGroupFieldSelector);
return useMemo(() => {
if (selectedGroup === undefined) {
return [];
}
let groups: Record<string, { id: string; label: string; value: string | boolean | undefined }> =
{};
const groupedEntries = groupBy(entries, entry => {
const group = getGroup(entry, selectedGroup);
groups = { ...groups, [group.id]: group };
return group.id;
});
const groupsArray: GroupOfEntries[] = Object.entries(groupedEntries).map(([id, entries]) => {
return {
...groups[id],
paths: new Set(entries.map(entry => entry.path)),
};
});
return groupsArray;
}, [entries, selectedGroup]);
}

View File

@ -2,11 +2,12 @@ import { useEffect, useState } from 'react';
import { emptyAsset, getAsset } from '@staticcms/core/actions/media'; import { emptyAsset, getAsset } from '@staticcms/core/actions/media';
import { useAppDispatch } from '@staticcms/core/store/hooks'; import { useAppDispatch } from '@staticcms/core/store/hooks';
import { isNotEmpty } from '../util/string.util'; import { isEmpty, isNotEmpty } from '../util/string.util';
import useDebounce from './useDebounce';
import type { Collection, Entry, FileOrImageField, MarkdownField } from '@staticcms/core/interface'; import type { Collection, Entry, MediaField } from '@staticcms/core/interface';
export default function useIsMediaAsset<T extends FileOrImageField | MarkdownField>( export default function useIsMediaAsset<T extends MediaField>(
url: string, url: string,
collection: Collection<T>, collection: Collection<T>,
field: T, field: T,
@ -14,17 +15,22 @@ export default function useIsMediaAsset<T extends FileOrImageField | MarkdownFie
): boolean { ): boolean {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [exists, setExists] = useState(false); const [exists, setExists] = useState(false);
const debouncedUrl = useDebounce(url, 200);
useEffect(() => { useEffect(() => {
if (isEmpty(debouncedUrl)) {
return;
}
const checkMediaExistence = async () => { const checkMediaExistence = async () => {
const asset = await dispatch(getAsset<T>(collection, entry, url, field)); const asset = await dispatch(getAsset<T>(collection, entry, debouncedUrl, field));
setExists( setExists(
Boolean(asset && asset !== emptyAsset && isNotEmpty(asset.toString()) && asset.fileObj), Boolean(asset && asset !== emptyAsset && isNotEmpty(asset.toString()) && asset.fileObj),
); );
}; };
checkMediaExistence(); checkMediaExistence();
}, [collection, dispatch, entry, field, url]); }, [collection, dispatch, entry, field, debouncedUrl]);
return exists; return exists;
} }

View File

@ -1,34 +1,39 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { emptyAsset, getAsset } from '@staticcms/core/actions/media'; import { emptyAsset, getAsset } from '@staticcms/core/actions/media';
import { useAppDispatch } from '@staticcms/core/store/hooks'; import { useAppDispatch } from '@staticcms/core/store/hooks';
import { isNotEmpty } from '../util/string.util'; import useDebounce from './useDebounce';
import type { Field, Collection, Entry } from '@staticcms/core/interface'; import type { Collection, Entry, MediaField } from '@staticcms/core/interface';
export default function useMediaAsset<T extends Field>( export default function useMediaAsset<T extends MediaField>(
url: string | undefined, url: string | undefined,
collection?: Collection<T>, collection?: Collection<T>,
field?: T, field?: T,
entry?: Entry, entry?: Entry,
): string { ): string {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [assetSource, setAssetSource] = useState(url); const [assetSource, setAssetSource] = useState('');
const debouncedUrl = useDebounce(url, 200);
useEffect(() => { useEffect(() => {
if (!url) { if (!debouncedUrl) {
return; return;
} }
const fetchMedia = async () => { const fetchMedia = async () => {
const asset = await dispatch(getAsset<T>(collection, entry, url, field)); const asset = await dispatch(getAsset<T>(collection, entry, debouncedUrl, field));
if (asset !== emptyAsset) { if (asset !== emptyAsset) {
setAssetSource(asset?.toString() ?? ''); setAssetSource(asset?.toString() ?? '');
} }
}; };
fetchMedia(); fetchMedia();
}, [collection, dispatch, entry, field, url]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedUrl]);
return isNotEmpty(assetSource) ? assetSource : url ?? ''; return useMemo(
() => (debouncedUrl?.startsWith('blob:') ? debouncedUrl : assetSource ?? ''),
[assetSource, debouncedUrl],
);
} }

View File

@ -5,17 +5,17 @@ import { openMediaLibrary, removeInsertedMedia } from '@staticcms/core/actions/m
import { selectMediaPath } from '@staticcms/core/reducers/selectors/mediaLibrary'; import { selectMediaPath } from '@staticcms/core/reducers/selectors/mediaLibrary';
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
import type { FileOrImageField, MarkdownField } from '@staticcms/core/interface'; import type { Collection, MediaField } from '@staticcms/core/interface';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
export default function useMediaInsert<T extends string | string[]>( export default function useMediaInsert<T extends string | string[], F extends MediaField>(
value: T, value: T,
options: { field?: FileOrImageField | MarkdownField; controlID?: string; forImage?: boolean }, options: { collection: Collection<F>; field: F; controlID?: string; forImage?: boolean },
callback: (newValue: T) => void, callback: (newValue: T) => void,
): (e?: MouseEvent) => void { ): (e?: MouseEvent) => void {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { controlID, field, forImage = false } = options; const { controlID, collection, field, forImage = false } = options;
const finalControlID = useMemo(() => controlID ?? uuid(), [controlID]); const finalControlID = useMemo(() => controlID ?? uuid(), [controlID]);
const mediaPathSelector = useMemo(() => selectMediaPath(finalControlID), [finalControlID]); const mediaPathSelector = useMemo(() => selectMediaPath(finalControlID), [finalControlID]);
@ -50,11 +50,12 @@ export default function useMediaInsert<T extends string | string[]>(
replaceIndex, replaceIndex,
allowMultiple: false, allowMultiple: false,
config, config,
collection,
field, field,
}), }),
); );
}, },
[dispatch, finalControlID, forImage, value, config, field], [dispatch, finalControlID, forImage, value, config, collection, field],
); );
return handleOpenMediaLibrary; return handleOpenMediaLibrary;

View File

@ -0,0 +1,22 @@
import { useMemo } from 'react';
import {
selectEntriesBySlugs,
selectPublishedSlugs,
} from '@staticcms/core/reducers/selectors/entries';
import { useAppSelector } from '@staticcms/core/store/hooks';
export default function usePublishedEntries(collectionName: string) {
const publishedSlugsSelector = useMemo(
() => selectPublishedSlugs(collectionName),
[collectionName],
);
const slugs = useAppSelector(publishedSlugsSelector);
const entries = useAppSelector(selectEntriesBySlugs);
return useMemo(
() => slugs && slugs.map(slug => entries[`${collectionName}.${slug}`]),
[collectionName, entries, slugs],
);
}

View File

@ -258,6 +258,7 @@ export function selectMediaFolders(config: Config, collection: Collection, entry
return [...new Set(folders)]; return [...new Set(folders)];
} }
export function getFieldsNames(fields: Field[] | undefined, prefix = '') { export function getFieldsNames(fields: Field[] | undefined, prefix = '') {
let names = fields?.map(f => `${prefix}${f.name}`) ?? []; let names = fields?.map(f => `${prefix}${f.name}`) ?? [];

View File

@ -0,0 +1,5 @@
export default class MediaLibraryCloseEvent extends CustomEvent<{}> {
constructor() {
super('mediaLibraryClose', {});
}
}

View File

@ -49,12 +49,12 @@ function hasCustomFolder(
if ('files' in collection) { if ('files' in collection) {
const file = getFileField(collection.files, slug); const file = getFileField(collection.files, slug);
if (file && file[folderKey]) { if (file && folderKey in file) {
return true; return true;
} }
} }
if (collection[folderKey]) { if (folderKey in collection) {
return true; return true;
} }
@ -72,7 +72,7 @@ function evaluateFolder(
const collection = { ...c }; const collection = { ...c };
// add identity template if doesn't exist // add identity template if doesn't exist
if (!collection[folderKey]) { if (!(folderKey in collection)) {
collection[folderKey] = `{{${folderKey}}}`; collection[folderKey] = `{{${folderKey}}}`;
} }

View File

@ -2,10 +2,12 @@ import { useEffect } from 'react';
import type AlertEvent from './events/AlertEvent'; import type AlertEvent from './events/AlertEvent';
import type ConfirmEvent from './events/ConfirmEvent'; import type ConfirmEvent from './events/ConfirmEvent';
import type MediaLibraryCloseEvent from './events/MediaLibraryCloseEvent';
interface EventMap { interface EventMap {
alert: AlertEvent; alert: AlertEvent;
confirm: ConfirmEvent; confirm: ConfirmEvent;
mediaLibraryClose: MediaLibraryCloseEvent;
} }
export function useWindowEvent<K extends keyof WindowEventMap>( export function useWindowEvent<K extends keyof WindowEventMap>(

View File

@ -21,7 +21,7 @@ import {
} from '../constants'; } from '../constants';
import type { MediaLibraryAction } from '../actions/mediaLibrary'; import type { MediaLibraryAction } from '../actions/mediaLibrary';
import type { Field, MediaFile, MediaLibraryInstance } from '../interface'; import type { Collection, Field, MediaFile, MediaLibraryInstance } from '../interface';
export interface MediaLibraryDisplayURL { export interface MediaLibraryDisplayURL {
url?: string; url?: string;
@ -39,6 +39,7 @@ export type MediaLibraryState = {
page?: number; page?: number;
files?: MediaFile[]; files?: MediaFile[];
config: Record<string, unknown>; config: Record<string, unknown>;
collection?: Collection;
field?: Field; field?: Field;
value?: string | string[]; value?: string | string[];
replaceIndex?: number; replaceIndex?: number;
@ -75,7 +76,8 @@ function mediaLibrary(
}; };
case MEDIA_LIBRARY_OPEN: { case MEDIA_LIBRARY_OPEN: {
const { controlID, forImage, config, field, value, replaceIndex } = action.payload; const { controlID, forImage, config, collection, field, value, replaceIndex } =
action.payload;
const libConfig = config || {}; const libConfig = config || {};
return { return {
@ -85,6 +87,7 @@ function mediaLibrary(
controlID, controlID,
canInsert: !!controlID, canInsert: !!controlID,
config: libConfig, config: libConfig,
collection,
field, field,
value, value,
replaceIndex, replaceIndex,

View File

@ -1,20 +1,9 @@
import get from 'lodash/get'; import get from 'lodash/get';
import groupBy from 'lodash/groupBy';
import orderBy from 'lodash/orderBy';
import { SORT_DIRECTION_ASCENDING, SORT_DIRECTION_NONE } from '@staticcms/core/constants'; import { SORT_DIRECTION_NONE } from '@staticcms/core/constants';
import { selectSortDataPath } from '@staticcms/core/lib/util/sort.util';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews'; import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
import type { import type { Entry, Group, GroupMap, Sort } from '@staticcms/core/interface';
Collection,
Entry,
Filter,
Group,
GroupMap,
GroupOfEntries,
Sort,
} from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store'; import type { RootState } from '@staticcms/core/store';
export function selectEntriesSort(entries: RootState, collection: string) { export function selectEntriesSort(entries: RootState, collection: string) {
@ -22,84 +11,49 @@ export function selectEntriesSort(entries: RootState, collection: string) {
return sort?.[collection]; return sort?.[collection];
} }
export function selectEntriesFilter(entries: RootState, collection: string) { export const selectEntriesFilter = (collectionName: string) => (entries: RootState) => {
const filter = entries.entries.filter as Filter | undefined; return entries.entries.filter?.[collectionName];
return filter?.[collection] || {}; };
}
export function selectEntriesGroup(entries: RootState, collection: string) { export function selectEntriesGroup(entries: RootState, collection: string) {
const group = entries.entries.group as Group | undefined; const group = entries.entries.group as Group | undefined;
return group?.[collection] || {}; return group?.[collection] || {};
} }
export function selectEntriesGroupField(entries: RootState, collection: string) { export const selectEntriesGroupField = (collection: string) => (entries: RootState) => {
const groups = selectEntriesGroup(entries, collection); const groups = selectEntriesGroup(entries, collection);
const value = Object.values(groups ?? {}).find(v => v?.active === true); return Object.values(groups ?? {}).find(v => v?.active === true);
return value; };
}
export function selectEntriesSortFields(entries: RootState, collection: string) { export const selectEntriesSortField = (collectionName: string) => (entries: RootState) => {
const sort = selectEntriesSort(entries, collection); const sort = selectEntriesSort(entries, collectionName);
const values = Object.values(sort ?? {}).filter(v => v?.direction !== SORT_DIRECTION_NONE) || []; return Object.values(sort ?? {}).find(v => v?.direction !== SORT_DIRECTION_NONE);
};
return values;
}
export function selectEntriesFilterFields(entries: RootState, collection: string) {
const filter = selectEntriesFilter(entries, collection);
const values = Object.values(filter ?? {}).filter(v => v?.active === true) || [];
return values;
}
export function selectViewStyle(entries: RootState): CollectionViewStyle { export function selectViewStyle(entries: RootState): CollectionViewStyle {
return entries.entries.viewStyle; return entries.entries.viewStyle;
} }
export function selectEntriesBySlugs(state: RootState) {
return state.entries.entities;
}
export function selectEntry(state: RootState, collection: string, slug: string) { export function selectEntry(state: RootState, collection: string, slug: string) {
return state.entries.entities[`${collection}.${slug}`]; return state.entries.entities[`${collection}.${slug}`];
} }
export function selectPublishedSlugs(state: RootState, collection: string) { export const selectPublishedSlugs = (collection: string) => (state: RootState) => {
return state.entries.pages[collection]?.ids ?? []; return state.entries.pages[collection]?.ids ?? [];
} };
function getPublishedEntries(state: RootState, collectionName: string) { export const selectPublishedEntries = (collectionName: string) => (state: RootState) => {
const slugs = selectPublishedSlugs(state, collectionName); const slugs = selectPublishedSlugs(collectionName)(state);
const entries = return (
slugs && (slugs.map(slug => selectEntry(state, collectionName, slug as string)) as Entry[]); slugs && (slugs.map(slug => selectEntry(state, collectionName, slug as string)) as Entry[])
return entries; );
} };
export function selectEntries(state: RootState, collection: Collection) { export function getGroup(entry: Entry, selectedGroup: GroupMap) {
const collectionName = collection.name;
let entries = getPublishedEntries(state, collectionName);
const sortFields = selectEntriesSortFields(state, collectionName);
if (sortFields && sortFields.length > 0) {
const keys = sortFields.map(v => selectSortDataPath(collection, v.key));
const orders = sortFields.map(v => (v.direction === SORT_DIRECTION_ASCENDING ? 'asc' : 'desc'));
entries = orderBy(entries, keys, orders);
}
const filters = selectEntriesFilterFields(state, collectionName);
if (filters && filters.length > 0) {
entries = entries.filter(e => {
const allMatched = filters.every(f => {
const pattern = f.pattern;
const field = f.field;
const data = e!.data || {};
const toMatch = get(data, field);
const matched = toMatch !== undefined && new RegExp(String(pattern)).test(String(toMatch));
return matched;
});
return allMatched;
});
}
return entries;
}
function getGroup(entry: Entry, selectedGroup: GroupMap) {
const label = selectedGroup.label; const label = selectedGroup.label;
const field = selectedGroup.field; const field = selectedGroup.field;
@ -139,35 +93,8 @@ function getGroup(entry: Entry, selectedGroup: GroupMap) {
}; };
} }
export function selectGroups(state: RootState, collection: Collection) {
const collectionName = collection.name;
const entries = getPublishedEntries(state, collectionName);
const selectedGroup = selectEntriesGroupField(state, collectionName);
if (selectedGroup === undefined) {
return [];
}
let groups: Record<string, { id: string; label: string; value: string | boolean | undefined }> =
{};
const groupedEntries = groupBy(entries, entry => {
const group = getGroup(entry, selectedGroup);
groups = { ...groups, [group.id]: group };
return group.id;
});
const groupsArray: GroupOfEntries[] = Object.entries(groupedEntries).map(([id, entries]) => {
return {
...groups[id],
paths: new Set(entries.map(entry => entry.path)),
};
});
return groupsArray;
}
export function selectEntryByPath(state: RootState, collection: string, path: string) { export function selectEntryByPath(state: RootState, collection: string, path: string) {
const slugs = selectPublishedSlugs(state, collection); const slugs = selectPublishedSlugs(collection)(state);
const entries = const entries =
slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as Entry[]); slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as Entry[]);

View File

@ -238,7 +238,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
const handleOpenMediaLibrary = useMediaInsert( const handleOpenMediaLibrary = useMediaInsert(
internalValue, internalValue,
{ field, controlID }, { collection, field, controlID, forImage },
handleOnChange, handleOnChange,
); );
@ -312,10 +312,11 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
replaceIndex: index, replaceIndex: index,
allowMultiple: false, allowMultiple: false,
config, config,
collection,
field, field,
}); });
}, },
[config, controlID, field, openMediaLibrary, internalValue], [openMediaLibrary, controlID, internalValue, config, collection, field],
); );
// TODO Readd when multiple uploads is supported // TODO Readd when multiple uploads is supported

View File

@ -2,7 +2,7 @@ import { styled } from '@mui/material/styles';
import partial from 'lodash/partial'; import partial from 'lodash/partial';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import EditorControl from '@staticcms/core/components/Editor/EditorControlPane/EditorControl'; import EditorControl from '@staticcms/core/components/editor/EditorControlPane/EditorControl';
import ListItemTopBar from '@staticcms/core/components/UI/ListItemTopBar'; import ListItemTopBar from '@staticcms/core/components/UI/ListItemTopBar';
import Outline from '@staticcms/core/components/UI/Outline'; import Outline from '@staticcms/core/components/UI/Outline';
import { colors } from '@staticcms/core/components/UI/styles'; import { colors } from '@staticcms/core/components/UI/styles';

View File

@ -52,7 +52,7 @@ const ListControlWrapper = createControlWrapper({
path: 'list', path: 'list',
}); });
jest.mock('@staticcms/core/components/Editor/EditorControlPane/EditorControl', () => { jest.mock('@staticcms/core/components/editor/EditorControlPane/EditorControl', () => {
return jest.fn(props => { return jest.fn(props => {
const { parentPath, field, value } = props; const { parentPath, field, value } = props;
return ( return (

View File

@ -7,6 +7,7 @@ import { getShortcodes } from '../../lib/registry';
import withShortcodeMdxComponent from './mdx/withShortcodeMdxComponent'; import withShortcodeMdxComponent from './mdx/withShortcodeMdxComponent';
import useMdx from './plate/hooks/useMdx'; import useMdx from './plate/hooks/useMdx';
import { processShortcodeConfigToMdx } from './plate/serialization/slate/processShortcodeConfig'; import { processShortcodeConfigToMdx } from './plate/serialization/slate/processShortcodeConfig';
import { withMdxImage } from '@staticcms/core/components/common/image/Image';
import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface'; import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react'; import type { FC } from 'react';
@ -26,13 +27,14 @@ function FallbackComponent({ error }: FallbackComponentProps) {
} }
const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewProps => { const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewProps => {
const { value } = previewProps; const { value, collection, field } = previewProps;
const components = useMemo( const components = useMemo(
() => ({ () => ({
Shortcode: withShortcodeMdxComponent({ previewProps }), Shortcode: withShortcodeMdxComponent({ previewProps }),
img: withMdxImage({ collection, field }),
}), }),
[previewProps], [collection, field, previewProps],
); );
const [state, setValue] = useMdx(value ?? ''); const [state, setValue] = useMdx(value ?? '');

View File

@ -5,11 +5,10 @@ import Popper from '@mui/material/Popper';
import { styled, useTheme } from '@mui/material/styles'; import { styled, useTheme } from '@mui/material/styles';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFocused } from 'slate-react';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
import useIsMediaAsset from '@staticcms/core/lib/hooks/useIsMediaAsset'; import useIsMediaAsset from '@staticcms/core/lib/hooks/useIsMediaAsset';
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert'; import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import type { Collection, Entry, FileOrImageField, MarkdownField } from '@staticcms/core/interface'; import type { Collection, Entry, FileOrImageField, MarkdownField } from '@staticcms/core/interface';
import type { ChangeEvent, KeyboardEvent } from 'react'; import type { ChangeEvent, KeyboardEvent } from 'react';
@ -65,7 +64,6 @@ export interface MediaPopoverProps<T extends FileOrImageField | MarkdownField> {
onUrlChange: (newValue: string) => void; onUrlChange: (newValue: string) => void;
onTextChange?: (newValue: string) => void; onTextChange?: (newValue: string) => void;
onClose: (shouldFocus: boolean) => void; onClose: (shouldFocus: boolean) => void;
mediaOpen?: boolean;
onMediaToggle?: (open: boolean) => void; onMediaToggle?: (open: boolean) => void;
onMediaChange: (newValue: string) => void; onMediaChange: (newValue: string) => void;
onRemove?: () => void; onRemove?: () => void;
@ -87,7 +85,6 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
onUrlChange, onUrlChange,
onTextChange, onTextChange,
onClose, onClose,
mediaOpen,
onMediaToggle, onMediaToggle,
onMediaChange, onMediaChange,
onRemove, onRemove,
@ -101,9 +98,9 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
const [editing, setEditing] = useState(inserting); const [editing, setEditing] = useState(inserting);
const hasEditorFocus = useFocused(); useWindowEvent('mediaLibraryClose', () => {
const [hasFocus, setHasFocus] = useState(false); onMediaToggle?.(false);
const debouncedHasFocus = useDebounce(hasFocus, 150); });
const handleClose = useCallback( const handleClose = useCallback(
(shouldFocus: boolean) => { (shouldFocus: boolean) => {
@ -155,60 +152,11 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
} }
}, [anchorEl, editing, inserting, urlDisabled]); }, [anchorEl, editing, inserting, urlDisabled]);
const [
{ prevAnchorEl, prevHasEditorFocus, prevHasFocus, prevDebouncedHasFocus },
setPrevFocusState,
] = useState<{
prevAnchorEl: HTMLElement | null;
prevHasEditorFocus: boolean;
prevHasFocus: boolean;
prevDebouncedHasFocus: boolean;
}>({
prevAnchorEl: anchorEl,
prevHasEditorFocus: hasEditorFocus,
prevHasFocus: hasFocus,
prevDebouncedHasFocus: debouncedHasFocus,
});
useEffect(() => {
if (mediaOpen) {
return;
}
if (anchorEl && !prevHasEditorFocus && hasEditorFocus) {
handleClose(false);
}
if (anchorEl && (prevHasFocus || prevDebouncedHasFocus) && !hasFocus && !debouncedHasFocus) {
handleClose(false);
}
setPrevFocusState({
prevAnchorEl: anchorEl,
prevHasEditorFocus: hasEditorFocus,
prevHasFocus: hasFocus,
prevDebouncedHasFocus: debouncedHasFocus,
});
}, [
anchorEl,
debouncedHasFocus,
handleClose,
hasEditorFocus,
hasFocus,
mediaOpen,
prevAnchorEl,
prevDebouncedHasFocus,
prevHasEditorFocus,
prevHasFocus,
]);
const handleFocus = useCallback(() => { const handleFocus = useCallback(() => {
setHasFocus(true);
onFocus?.(); onFocus?.();
}, [onFocus]); }, [onFocus]);
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setHasFocus(false);
onBlur?.(); onBlur?.();
}, [onBlur]); }, [onBlur]);
@ -220,7 +168,11 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
[onMediaChange, onMediaToggle], [onMediaChange, onMediaToggle],
); );
const handleOpenMediaLibrary = useMediaInsert(url, { field, forImage }, handleMediaChange); const handleOpenMediaLibrary = useMediaInsert(
url,
{ collection, field, forImage },
handleMediaChange,
);
const handleMediaOpen = useCallback(() => { const handleMediaOpen = useCallback(() => {
onMediaToggle?.(true); onMediaToggle?.(true);

View File

@ -12,6 +12,7 @@ import { useFocused } from 'slate-react';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset'; import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import { isEmpty } from '@staticcms/core/lib/util/string.util'; import { isEmpty } from '@staticcms/core/lib/util/string.util';
import { MediaPopover } from '@staticcms/markdown'; import { MediaPopover } from '@staticcms/markdown';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface'; import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
import type { MdImageElement, MdValue } from '@staticcms/markdown'; import type { MdImageElement, MdValue } from '@staticcms/markdown';
@ -35,13 +36,32 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE
const { url, alt } = element; const { url, alt } = element;
const [internalUrl, setInternalUrl] = useState(url); const [internalUrl, setInternalUrl] = useState(url);
const [internalAlt, setInternalAlt] = useState(alt); const [internalAlt, setInternalAlt] = useState(alt);
const [popoverHasFocus, setPopoverHasFocus] = useState(false);
const debouncedPopoverHasFocus = useDebounce(popoverHasFocus, 100);
const [mediaOpen, setMediaOpen] = useState(false);
const imageRef = useRef<HTMLImageElement | null>(null); const imageRef = useRef<HTMLImageElement | null>(null);
const [anchorEl, setAnchorEl] = useState<HTMLImageElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLImageElement | null>(null);
const hasEditorFocus = useFocused(); const hasEditorFocus = useFocused();
const debouncedHasEditorFocus = useDebounce(hasEditorFocus, 100);
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setAnchorEl(null); if (!popoverHasFocus && !mediaOpen) {
setAnchorEl(null);
}
}, [mediaOpen, popoverHasFocus]);
const handlePopoverFocus = useCallback(() => {
setPopoverHasFocus(true);
}, []);
const handlePopoverBlur = useCallback(() => {
setPopoverHasFocus(false);
}, []);
const handleMediaToggle = useCallback(() => {
setMediaOpen(oldMediaOpen => !oldMediaOpen);
}, []); }, []);
const handleChange = useCallback( const handleChange = useCallback(
@ -96,7 +116,28 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE
const selection = usePlateSelection(); const selection = usePlateSelection();
useEffect(() => { useEffect(() => {
if (!hasEditorFocus || !selection) { if (
hasEditorFocus ||
debouncedHasEditorFocus ||
mediaOpen ||
popoverHasFocus ||
debouncedPopoverHasFocus
) {
return;
}
handleClose();
}, [
debouncedHasEditorFocus,
debouncedPopoverHasFocus,
handleClose,
hasEditorFocus,
mediaOpen,
popoverHasFocus,
]);
useEffect(() => {
if (!hasEditorFocus || !selection || mediaOpen || popoverHasFocus) {
return; return;
} }
@ -109,12 +150,24 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE
} }
if (node !== element && node !== firstChild) { if (node !== element && node !== firstChild) {
handleClose(); if (anchorEl) {
handleClose();
}
return; return;
} }
handleOpenPopover(); handleOpenPopover();
}, [handleClose, hasEditorFocus, element, selection, editor, handleOpenPopover]); }, [
handleClose,
hasEditorFocus,
element,
selection,
editor,
handleOpenPopover,
mediaOpen,
popoverHasFocus,
anchorEl,
]);
return ( return (
<span onBlur={handleBlur}> <span onBlur={handleBlur}>
@ -140,6 +193,9 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE
onMediaChange={handleMediaChange} onMediaChange={handleMediaChange}
onRemove={handleRemove} onRemove={handleRemove}
forImage forImage
onFocus={handlePopoverFocus}
onBlur={handlePopoverBlur}
onMediaToggle={handleMediaToggle}
/> />
{children} {children}
</span> </span>

View File

@ -2,17 +2,21 @@ import {
findNodePath, findNodePath,
focusEditor, focusEditor,
getEditorString, getEditorString,
getNode,
replaceNodeChildren,
setNodes, setNodes,
unwrapLink, unwrapLink,
upsertLink, usePlateSelection,
} from '@udecode/plate'; } from '@udecode/plate';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useFocused } from 'slate-react';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
import MediaPopover from '../../common/MediaPopover'; import MediaPopover from '../../common/MediaPopover';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface'; import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
import type { MdLinkElement, MdValue } from '@staticcms/markdown'; import type { MdLinkElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate'; import type { PlateRenderElementProps, TText } from '@udecode/plate';
import type { FC, MouseEvent } from 'react'; import type { FC, MouseEvent } from 'react';
export interface WithLinkElementProps { export interface WithLinkElementProps {
@ -24,19 +28,49 @@ export interface WithLinkElementProps {
const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkElementProps) => { const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkElementProps) => {
const LinkElement: FC<PlateRenderElementProps<MdValue, MdLinkElement>> = ({ const LinkElement: FC<PlateRenderElementProps<MdValue, MdLinkElement>> = ({
attributes, attributes: { ref: _ref, ...attributes },
children, children,
nodeProps, nodeProps,
element, element,
editor, editor,
}) => { }) => {
const [anchorEl, setAnchorEl] = useState<HTMLAnchorElement | null>(null); const urlRef = useRef<HTMLAnchorElement | null>(null);
const { url } = element; const { url } = element;
const path = findNodePath(editor, element); const path = findNodePath(editor, element);
const [internalUrl, setInternalUrl] = useState(url); const [internalUrl, setInternalUrl] = useState(url);
const [internalText, setInternalText] = useState(getEditorString(editor, path)); const [internalText, setInternalText] = useState(getEditorString(editor, path));
const [popoverHasFocus, setPopoverHasFocus] = useState(false);
const debouncedPopoverHasFocus = useDebounce(popoverHasFocus, 100);
const [mediaOpen, setMediaOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState<HTMLAnchorElement | null>(null);
const hasEditorFocus = useFocused();
const debouncedHasEditorFocus = useDebounce(hasEditorFocus, 100);
const handleOpenPopover = useCallback(() => {
setAnchorEl(urlRef.current);
}, []);
const handleBlur = useCallback(() => {
if (!popoverHasFocus && !mediaOpen) {
setAnchorEl(null);
}
}, [mediaOpen, popoverHasFocus]);
const handlePopoverFocus = useCallback(() => {
setPopoverHasFocus(true);
}, []);
const handlePopoverBlur = useCallback(() => {
setPopoverHasFocus(false);
}, []);
const handleMediaToggle = useCallback(() => {
setMediaOpen(oldMediaOpen => !oldMediaOpen);
}, []);
const handleClick = useCallback((event: MouseEvent<HTMLAnchorElement>) => { const handleClick = useCallback((event: MouseEvent<HTMLAnchorElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
@ -50,11 +84,29 @@ const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkEle
focusEditor(editor, editor.selection); focusEditor(editor, editor.selection);
}, [editor]); }, [editor]);
const selection = usePlateSelection();
const handleChange = useCallback( const handleChange = useCallback(
(newUrl: string, newText: string) => { (newUrl: string, newText: string) => {
const path = findNodePath(editor, element); const path = findNodePath(editor, element);
path && setNodes<MdLinkElement>(editor, { url: newUrl }, { at: path });
upsertLink(editor, { url: newUrl, text: newText }); if (path) {
setNodes(
editor,
{ ...element, url: newUrl, children: [{ text: newText }] },
{ at: path },
);
if (newText?.length && newText !== getEditorString(editor, path)) {
replaceNodeChildren<TText>(editor, {
at: path,
nodes: { text: newText },
insertOptions: {
select: true,
},
});
}
}
}, },
[editor, element], [editor, element],
); );
@ -72,9 +124,83 @@ const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkEle
handleChange(internalUrl, internalText); handleChange(internalUrl, internalText);
}, [handleChange, internalText, internalUrl]); }, [handleChange, internalText, internalUrl]);
useEffect(() => {
if (
hasEditorFocus ||
debouncedHasEditorFocus ||
mediaOpen ||
popoverHasFocus ||
debouncedPopoverHasFocus
) {
return;
}
handleClose();
}, [
debouncedHasEditorFocus,
debouncedPopoverHasFocus,
handleClose,
hasEditorFocus,
mediaOpen,
popoverHasFocus,
]);
useEffect(() => {
if (
hasEditorFocus ||
debouncedHasEditorFocus ||
mediaOpen ||
popoverHasFocus ||
debouncedPopoverHasFocus
) {
return;
}
handleClose();
}, [
debouncedHasEditorFocus,
debouncedPopoverHasFocus,
handleClose,
hasEditorFocus,
mediaOpen,
popoverHasFocus,
]);
useEffect(() => {
if (!hasEditorFocus || !selection || mediaOpen || popoverHasFocus) {
return;
}
const node = getNode(editor, selection.anchor.path);
const firstChild =
'children' in element && element.children.length > 0 ? element.children[0] : undefined;
if (!node) {
return;
}
if (node !== element && node !== firstChild) {
if (anchorEl) {
handleClose();
}
return;
}
handleOpenPopover();
}, [
handleClose,
hasEditorFocus,
element,
selection,
editor,
handleOpenPopover,
mediaOpen,
popoverHasFocus,
anchorEl,
]);
return ( return (
<> <span onBlur={handleBlur}>
<a {...attributes} href={url} {...nodeProps} onClick={handleClick}> <a ref={urlRef} {...attributes} href={url} {...nodeProps} onClick={handleClick}>
{children} {children}
</a> </a>
<MediaPopover <MediaPopover
@ -90,8 +216,11 @@ const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkEle
onClose={handleClose} onClose={handleClose}
onMediaChange={handleMediaChange} onMediaChange={handleMediaChange}
onRemove={handleRemove} onRemove={handleRemove}
onFocus={handlePopoverFocus}
onBlur={handlePopoverBlur}
onMediaToggle={handleMediaToggle}
/> />
</> </span>
); );
}; };

View File

@ -1,7 +1,7 @@
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import EditorControl from '@staticcms/core/components/Editor/EditorControlPane/EditorControl'; import EditorControl from '@staticcms/core/components/editor/EditorControlPane/EditorControl';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar'; import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline'; import Outline from '@staticcms/core/components/UI/Outline';
import { transientOptions } from '@staticcms/core/lib'; import { transientOptions } from '@staticcms/core/lib';

View File

@ -35,7 +35,7 @@ const ObjectControlWrapper = createControlWrapper({
path: 'object', path: 'object',
}); });
jest.mock('@staticcms/core/components/Editor/EditorControlPane/EditorControl', () => { jest.mock('@staticcms/core/components/editor/EditorControlPane/EditorControl', () => {
return jest.fn(props => { return jest.fn(props => {
const { parentPath, fieldName, field } = props; const { parentPath, fieldName, field } = props;
return ( return (