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",
"posts": {
"front_limit": 4,
"author": "Bob",
"thumb": "/backends/proxy/assets/upload/kanefreeman_2.jpg"
}
}
}

View File

@ -2,7 +2,7 @@
title: Something something something...
draft: false
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

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

View File

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

View File

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

View File

@ -34,6 +34,7 @@ import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type {
BaseField,
Collection,
DisplayURLState,
Field,
ImplementationMediaFile,
@ -83,16 +84,28 @@ export function openMediaLibrary<F extends BaseField = UnknownField>(
allowMultiple?: boolean;
replaceIndex?: number;
config?: Record<string, unknown>;
collection?: Collection<F>;
field?: F;
} = {},
) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
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) {
mediaLibrary.show({ id: controlID, value, config, allowMultiple, imagesOnly: forImage });
}
dispatch(
mediaLibraryOpened({
controlID,
@ -101,6 +114,7 @@ export function openMediaLibrary<F extends BaseField = UnknownField>(
allowMultiple,
replaceIndex,
config,
collection: collection as Collection,
field: field as Field,
}),
);
@ -406,6 +420,7 @@ function mediaLibraryOpened(payload: {
replaceIndex?: number;
allowMultiple?: boolean;
config?: Record<string, unknown>;
collection?: Collection;
field?: Field;
}) {
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 { history } from '@staticcms/core/routing/history';
import { getDefaultPath } from '../../lib/util/collection.util';
import CollectionRoute from '../Collection/CollectionRoute';
import EditorRoute from '../Editor/EditorRoute';
import CollectionRoute from '../collection/CollectionRoute';
import EditorRoute from '../editor/EditorRoute';
import MediaLibrary from '../MediaLibrary/MediaLibrary';
import Page from '../page/Page';
import Snackbars from '../snackbar/Snackbars';
@ -183,7 +183,7 @@ const App = ({
element={<EditorRoute collections={collections} newRecord />}
/>
<Route
path="/collections/:name/entries/:slug"
path="/collections/:name/entries/*"
element={<EditorRoute collections={collections} />}
/>
<Route

View File

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

View File

@ -1,22 +1,17 @@
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 { connect } from 'react-redux';
import {
loadEntries as loadEntriesAction,
traverseCollectionCursor as traverseCollectionCursorAction,
} from '@staticcms/core/actions/entries';
import { loadEntries, traverseCollectionCursor } from '@staticcms/core/actions/entries';
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 { selectCollectionEntriesCursor } from '@staticcms/core/reducers/selectors/cursors';
import {
selectEntries,
selectEntriesLoaded,
selectGroups,
selectIsFetching,
} from '@staticcms/core/reducers/selectors/entries';
import { selectEntriesLoaded, selectIsFetching } from '@staticcms/core/reducers/selectors/entries';
import Entries from './Entries';
import { useAppDispatch } from '@staticcms/core/store/hooks';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '@staticcms/core/interface';
@ -48,90 +43,6 @@ function getGroupTitle(group: GroupOfEntries, t: t) {
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[]) {
const filtered = entries.filter(e => {
const entryPath = e.path.slice(collectionFolder.length + 1);
@ -152,6 +63,94 @@ export function filterNestedEntries(path: string, collectionFolder: string, entr
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 {
collection: Collection;
viewStyle: CollectionViewStyle;
@ -163,27 +162,16 @@ function mapStateToProps(state: RootState, ownProps: EntriesCollectionOwnProps)
const { collection, viewStyle, filterTerm } = ownProps;
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 isFetching = selectIsFetching(state, collection.name);
const rawCursor = selectCollectionEntriesCursor(state, collection.name);
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 = {
loadEntries: loadEntriesAction,
traverseCollectionCursor: traverseCollectionCursorAction,
};
const mapDispatchToProps = {};
const connector = connect(mapStateToProps, mapDispatchToProps);
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 Typography from '@mui/material/Typography';
import React, { useMemo } from 'react';
import { connect } from 'react-redux';
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 useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import { getPreviewCard } from '@staticcms/core/lib/registry';
@ -17,28 +15,58 @@ import {
selectTemplateName,
} from '@staticcms/core/lib/util/collection.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectIsLoadingAsset } from '@staticcms/core/reducers/selectors/medias';
import { useAppSelector } from '@staticcms/core/store/hooks';
import useWidgetsFor from '../../common/widget/useWidgetsFor';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
import type { Collection, Entry, Field } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { ConnectedProps } from 'react-redux';
import type { Collection, Entry, FileOrImageField, MediaField } from '@staticcms/core/interface';
export interface EntryCardProps {
entry: Entry;
imageFieldName?: string | null | undefined;
collection: Collection;
collectionLabel?: string;
viewStyle?: CollectionViewStyle;
}
const EntryCard = ({
collection,
entry,
path,
image,
imageField,
collectionLabel,
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 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);
@ -97,51 +125,4 @@ const EntryCard = ({
);
};
interface EntryCardOwnProps {
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);
export default EntryCard;

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
import fuzzy from 'fuzzy';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import {
@ -12,12 +11,13 @@ import {
persistMedia as persistMediaAction,
} from '@staticcms/core/actions/mediaLibrary';
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 alert from '../UI/Alert';
import confirm from '../UI/Confirm';
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 { ChangeEvent, KeyboardEvent } from 'react';
import type { ConnectedProps } from 'react-redux';
@ -53,7 +53,7 @@ const MediaLibrary = ({
isDeleting,
hasNextPage,
isPaginating,
config,
config: mediaConfig,
loadMedia,
dynamicSearchQuery,
page,
@ -61,31 +61,28 @@ const MediaLibrary = ({
deleteMedia,
insertMedia,
closeMediaLibrary,
collection,
field,
t,
}: TranslatedProps<MediaLibraryProps>) => {
}: MediaLibraryProps) => {
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
const [query, setQuery] = useState<string | undefined>(undefined);
const [prevIsVisible, setPrevIsVisible] = useState(false);
useEffect(() => {
loadMedia();
loadMedia({});
}, [loadMedia]);
useEffect(() => {
if (!prevIsVisible && isVisible) {
setSelectedFile(null);
setQuery('');
loadMedia();
} else if (prevIsVisible && !isVisible) {
window.dispatchEvent(new MediaLibraryCloseEvent());
}
setPrevIsVisible(isVisible);
}, [isVisible, prevIsVisible]);
useEffect(() => {
if (!prevIsVisible && isVisible) {
loadMedia();
}
}, [isVisible, loadMedia, prevIsVisible]);
const loadDisplayURL = useCallback(
@ -155,7 +152,7 @@ const MediaLibrary = ({
[selectedFile?.key],
);
const scrollContainerRef = useRef<HTMLDivElement>();
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const scrollToTop = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
@ -189,7 +186,8 @@ const MediaLibrary = ({
event.preventDefault();
const files = [...Array.from(fileList)];
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) {
alert({
@ -213,7 +211,7 @@ const MediaLibrary = ({
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.
*/
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
* potential matches, so we strip them all out internally before running the
@ -318,11 +316,10 @@ const MediaLibrary = ({
*/
const strippedQuery = query.replace(/ /g, '');
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];
return { ...file, queryIndex };
});
return matchFiles;
}) as MediaFile[];
}, []);
return (
@ -339,7 +336,7 @@ const MediaLibrary = ({
hasNextPage={hasNextPage}
isPaginating={isPaginating}
query={query}
selectedFile={selectedFile}
selectedFile={selectedFile ?? undefined}
handleFilter={filterImages}
handleQuery={queryFilter}
toTableData={toTableData}
@ -350,12 +347,13 @@ const MediaLibrary = ({
handleDelete={handleDelete}
handleInsert={handleInsert}
handleDownload={handleDownload}
setScrollContainerRef={scrollContainerRef}
scrollContainerRef={scrollContainerRef}
handleAssetClick={handleAssetClick}
handleLoadMore={handleLoadMore}
displayURLs={displayURLs}
loadDisplayURL={loadDisplayURL}
t={t}
collection={collection}
field={field}
/>
);
};
@ -379,6 +377,7 @@ function mapStateToProps(state: RootState) {
page: mediaLibrary.page,
hasNextPage: mediaLibrary.hasNextPage,
isPaginating: mediaLibrary.isPaginating,
collection: mediaLibrary.collection,
field,
};
return { ...mediaLibraryProps };
@ -396,4 +395,4 @@ const mapDispatchToProps = {
const connector = connect(mapStateToProps, mapDispatchToProps);
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 useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
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 { Field, Collection } from '@staticcms/core/interface';
const IMAGE_HEIGHT = 160;
@ -89,6 +92,8 @@ interface MediaLibraryCardProps {
isViewableImage: boolean;
loadDisplayURL: () => void;
isDraft?: boolean;
collection?: Collection;
field?: Field;
}
const MediaLibraryCard = ({
@ -103,9 +108,12 @@ const MediaLibraryCard = ({
type,
isViewableImage,
isDraft,
collection,
field,
loadDisplayURL,
}: MediaLibraryCardProps) => {
const url = useMediaAsset(displayURL.url);
const entry = useAppSelector(selectEditingDraft);
const url = useMediaAsset(displayURL.url, collection, field, entry);
useEffect(() => {
if (!displayURL.url) {

View File

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

View File

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

View File

@ -535,6 +535,12 @@ export interface BaseField {
widget: string;
}
export interface MediaField extends BaseField {
media_library?: MediaLibrary;
media_folder?: string;
public_folder?: string;
}
export interface BooleanField extends BaseField {
widget: 'boolean';
default?: boolean;
@ -572,13 +578,9 @@ export interface DateTimeField extends BaseField {
picker_utc?: boolean;
}
export interface FileOrImageField extends BaseField {
export interface FileOrImageField extends MediaField {
widget: 'file' | 'image';
default?: string;
media_library?: MediaLibrary;
media_folder?: string;
public_folder?: string;
}
export interface ObjectField<EF extends BaseField = UnknownField> extends BaseField {
@ -614,13 +616,9 @@ export interface MapField extends BaseField {
height?: string;
}
export interface MarkdownField extends BaseField {
export interface MarkdownField extends MediaField {
widget: 'markdown';
default?: string;
media_library?: MediaLibrary;
media_folder?: string;
public_folder?: string;
}
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 { 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,
collection: Collection<T>,
field: T,
@ -14,17 +15,22 @@ export default function useIsMediaAsset<T extends FileOrImageField | MarkdownFie
): boolean {
const dispatch = useAppDispatch();
const [exists, setExists] = useState(false);
const debouncedUrl = useDebounce(url, 200);
useEffect(() => {
if (isEmpty(debouncedUrl)) {
return;
}
const checkMediaExistence = async () => {
const asset = await dispatch(getAsset<T>(collection, entry, url, field));
const asset = await dispatch(getAsset<T>(collection, entry, debouncedUrl, field));
setExists(
Boolean(asset && asset !== emptyAsset && isNotEmpty(asset.toString()) && asset.fileObj),
);
};
checkMediaExistence();
}, [collection, dispatch, entry, field, url]);
}, [collection, dispatch, entry, field, debouncedUrl]);
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 { 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,
collection?: Collection<T>,
field?: T,
entry?: Entry,
): string {
const dispatch = useAppDispatch();
const [assetSource, setAssetSource] = useState(url);
const [assetSource, setAssetSource] = useState('');
const debouncedUrl = useDebounce(url, 200);
useEffect(() => {
if (!url) {
if (!debouncedUrl) {
return;
}
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) {
setAssetSource(asset?.toString() ?? '');
}
};
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 { 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';
export default function useMediaInsert<T extends string | string[]>(
export default function useMediaInsert<T extends string | string[], F extends MediaField>(
value: T,
options: { field?: FileOrImageField | MarkdownField; controlID?: string; forImage?: boolean },
options: { collection: Collection<F>; field: F; controlID?: string; forImage?: boolean },
callback: (newValue: T) => void,
): (e?: MouseEvent) => void {
const dispatch = useAppDispatch();
const { controlID, field, forImage = false } = options;
const { controlID, collection, field, forImage = false } = options;
const finalControlID = useMemo(() => controlID ?? uuid(), [controlID]);
const mediaPathSelector = useMemo(() => selectMediaPath(finalControlID), [finalControlID]);
@ -50,11 +50,12 @@ export default function useMediaInsert<T extends string | string[]>(
replaceIndex,
allowMultiple: false,
config,
collection,
field,
}),
);
},
[dispatch, finalControlID, forImage, value, config, field],
[dispatch, finalControlID, forImage, value, config, collection, field],
);
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)];
}
export function getFieldsNames(fields: Field[] | undefined, prefix = '') {
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) {
const file = getFileField(collection.files, slug);
if (file && file[folderKey]) {
if (file && folderKey in file) {
return true;
}
}
if (collection[folderKey]) {
if (folderKey in collection) {
return true;
}
@ -72,7 +72,7 @@ function evaluateFolder(
const collection = { ...c };
// add identity template if doesn't exist
if (!collection[folderKey]) {
if (!(folderKey in collection)) {
collection[folderKey] = `{{${folderKey}}}`;
}

View File

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

View File

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

View File

@ -1,20 +1,9 @@
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 { selectSortDataPath } from '@staticcms/core/lib/util/sort.util';
import { SORT_DIRECTION_NONE } from '@staticcms/core/constants';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
import type {
Collection,
Entry,
Filter,
Group,
GroupMap,
GroupOfEntries,
Sort,
} from '@staticcms/core/interface';
import type { Entry, Group, GroupMap, Sort } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
export function selectEntriesSort(entries: RootState, collection: string) {
@ -22,84 +11,49 @@ export function selectEntriesSort(entries: RootState, collection: string) {
return sort?.[collection];
}
export function selectEntriesFilter(entries: RootState, collection: string) {
const filter = entries.entries.filter as Filter | undefined;
return filter?.[collection] || {};
}
export const selectEntriesFilter = (collectionName: string) => (entries: RootState) => {
return entries.entries.filter?.[collectionName];
};
export function selectEntriesGroup(entries: RootState, collection: string) {
const group = entries.entries.group as Group | undefined;
return group?.[collection] || {};
}
export function selectEntriesGroupField(entries: RootState, collection: string) {
export const selectEntriesGroupField = (collection: string) => (entries: RootState) => {
const groups = selectEntriesGroup(entries, collection);
const value = Object.values(groups ?? {}).find(v => v?.active === true);
return value;
}
return Object.values(groups ?? {}).find(v => v?.active === true);
};
export function selectEntriesSortFields(entries: RootState, collection: string) {
const sort = selectEntriesSort(entries, collection);
const values = Object.values(sort ?? {}).filter(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 const selectEntriesSortField = (collectionName: string) => (entries: RootState) => {
const sort = selectEntriesSort(entries, collectionName);
return Object.values(sort ?? {}).find(v => v?.direction !== SORT_DIRECTION_NONE);
};
export function selectViewStyle(entries: RootState): CollectionViewStyle {
return entries.entries.viewStyle;
}
export function selectEntriesBySlugs(state: RootState) {
return state.entries.entities;
}
export function selectEntry(state: RootState, collection: string, slug: string) {
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 ?? [];
}
};
function getPublishedEntries(state: RootState, collectionName: string) {
const slugs = selectPublishedSlugs(state, collectionName);
const entries =
slugs && (slugs.map(slug => selectEntry(state, collectionName, slug as string)) as Entry[]);
return entries;
}
export const selectPublishedEntries = (collectionName: string) => (state: RootState) => {
const slugs = selectPublishedSlugs(collectionName)(state);
return (
slugs && (slugs.map(slug => selectEntry(state, collectionName, slug as string)) as Entry[])
);
};
export function selectEntries(state: RootState, collection: Collection) {
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) {
export function getGroup(entry: Entry, selectedGroup: GroupMap) {
const label = selectedGroup.label;
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) {
const slugs = selectPublishedSlugs(state, collection);
const slugs = selectPublishedSlugs(collection)(state);
const entries =
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(
internalValue,
{ field, controlID },
{ collection, field, controlID, forImage },
handleOnChange,
);
@ -312,10 +312,11 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
replaceIndex: index,
allowMultiple: false,
config,
collection,
field,
});
},
[config, controlID, field, openMediaLibrary, internalValue],
[openMediaLibrary, controlID, internalValue, config, collection, field],
);
// 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 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 Outline from '@staticcms/core/components/UI/Outline';
import { colors } from '@staticcms/core/components/UI/styles';

View File

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

View File

@ -7,6 +7,7 @@ import { getShortcodes } from '../../lib/registry';
import withShortcodeMdxComponent from './mdx/withShortcodeMdxComponent';
import useMdx from './plate/hooks/useMdx';
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 { FC } from 'react';
@ -26,13 +27,14 @@ function FallbackComponent({ error }: FallbackComponentProps) {
}
const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewProps => {
const { value } = previewProps;
const { value, collection, field } = previewProps;
const components = useMemo(
() => ({
Shortcode: withShortcodeMdxComponent({ previewProps }),
img: withMdxImage({ collection, field }),
}),
[previewProps],
[collection, field, previewProps],
);
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 TextField from '@mui/material/TextField';
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 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 { ChangeEvent, KeyboardEvent } from 'react';
@ -65,7 +64,6 @@ export interface MediaPopoverProps<T extends FileOrImageField | MarkdownField> {
onUrlChange: (newValue: string) => void;
onTextChange?: (newValue: string) => void;
onClose: (shouldFocus: boolean) => void;
mediaOpen?: boolean;
onMediaToggle?: (open: boolean) => void;
onMediaChange: (newValue: string) => void;
onRemove?: () => void;
@ -87,7 +85,6 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
onUrlChange,
onTextChange,
onClose,
mediaOpen,
onMediaToggle,
onMediaChange,
onRemove,
@ -101,9 +98,9 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
const [editing, setEditing] = useState(inserting);
const hasEditorFocus = useFocused();
const [hasFocus, setHasFocus] = useState(false);
const debouncedHasFocus = useDebounce(hasFocus, 150);
useWindowEvent('mediaLibraryClose', () => {
onMediaToggle?.(false);
});
const handleClose = useCallback(
(shouldFocus: boolean) => {
@ -155,60 +152,11 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
}
}, [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(() => {
setHasFocus(true);
onFocus?.();
}, [onFocus]);
const handleBlur = useCallback(() => {
setHasFocus(false);
onBlur?.();
}, [onBlur]);
@ -220,7 +168,11 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
[onMediaChange, onMediaToggle],
);
const handleOpenMediaLibrary = useMediaInsert(url, { field, forImage }, handleMediaChange);
const handleOpenMediaLibrary = useMediaInsert(
url,
{ collection, field, forImage },
handleMediaChange,
);
const handleMediaOpen = useCallback(() => {
onMediaToggle?.(true);

View File

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

View File

@ -2,17 +2,21 @@ import {
findNodePath,
focusEditor,
getEditorString,
getNode,
replaceNodeChildren,
setNodes,
unwrapLink,
upsertLink,
usePlateSelection,
} 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 type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
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';
export interface WithLinkElementProps {
@ -24,19 +28,49 @@ export interface WithLinkElementProps {
const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkElementProps) => {
const LinkElement: FC<PlateRenderElementProps<MdValue, MdLinkElement>> = ({
attributes,
attributes: { ref: _ref, ...attributes },
children,
nodeProps,
element,
editor,
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLAnchorElement | null>(null);
const urlRef = useRef<HTMLAnchorElement | null>(null);
const { url } = element;
const path = findNodePath(editor, element);
const [internalUrl, setInternalUrl] = useState(url);
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>) => {
setAnchorEl(event.currentTarget);
@ -50,11 +84,29 @@ const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkEle
focusEditor(editor, editor.selection);
}, [editor]);
const selection = usePlateSelection();
const handleChange = useCallback(
(newUrl: string, newText: string) => {
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],
);
@ -72,9 +124,83 @@ const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkEle
handleChange(internalUrl, internalText);
}, [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 (
<>
<a {...attributes} href={url} {...nodeProps} onClick={handleClick}>
<span onBlur={handleBlur}>
<a ref={urlRef} {...attributes} href={url} {...nodeProps} onClick={handleClick}>
{children}
</a>
<MediaPopover
@ -90,8 +216,11 @@ const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkEle
onClose={handleClose}
onMediaChange={handleMediaChange}
onRemove={handleRemove}
onFocus={handlePopoverFocus}
onBlur={handlePopoverBlur}
onMediaToggle={handleMediaToggle}
/>
</>
</span>
);
};

View File

@ -1,7 +1,7 @@
import { styled } from '@mui/material/styles';
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 Outline from '@staticcms/core/components/UI/Outline';
import { transientOptions } from '@staticcms/core/lib';

View File

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