diff --git a/packages/core/dev-test/backends/github/config.yml b/packages/core/dev-test/backends/github/config.yml index 86bc4cd5..3104d449 100644 --- a/packages/core/dev-test/backends/github/config.yml +++ b/packages/core/dev-test/backends/github/config.yml @@ -5,12 +5,12 @@ backend: media_folder: assets/upload public_folder: /assets/upload +media_library: + max_file_size: 100000000 collections: - name: posts label: Posts label_singular: Post - media_folder: /assets/posts - public_folder: /assets/posts description: > The description is a great place for tone setting, high level information, and editing guidelines that are specific to a collection. diff --git a/packages/core/dev-test/backends/gitlab/config.yml b/packages/core/dev-test/backends/gitlab/config.yml index 10e99fb0..d02874f2 100644 --- a/packages/core/dev-test/backends/gitlab/config.yml +++ b/packages/core/dev-test/backends/gitlab/config.yml @@ -3,10 +3,12 @@ backend: branch: main repo: static-cms/static-cms-gitlab auth_type: pkce - app_id: 91cc479ec663625098d456850c4dc4943fd8462064ebd9693b330e66f9d8f11a + app_id: 589ccb30ce31da8ba08e0b1e10b681a10be108e144508c2712573c6e4d60707e media_folder: assets/upload public_folder: /assets/upload +media_library: + max_file_size: 100000000 collections: - name: posts label: Posts @@ -17,12 +19,14 @@ collections: folder: _posts slug: '{{year}}-{{month}}-{{day}}-{{slug}}' summary: '{{title}} -- {{year}}/{{month}}/{{day}}' + summary_fields: + - title + - date + - draft sortable_fields: fields: - title - date - default: - field: title create: true view_filters: - label: Posts With Index diff --git a/packages/core/dev-test/backends/gitlab/index.html b/packages/core/dev-test/backends/gitlab/index.html index a63fb7a5..e2ff9173 100644 --- a/packages/core/dev-test/backends/gitlab/index.html +++ b/packages/core/dev-test/backends/gitlab/index.html @@ -7,6 +7,6 @@ - + diff --git a/packages/core/dev-test/backends/gitlab/index.js b/packages/core/dev-test/backends/gitlab/index.js new file mode 100644 index 00000000..db80e979 --- /dev/null +++ b/packages/core/dev-test/backends/gitlab/index.js @@ -0,0 +1,197 @@ +// Register all the things +CMS.init(); + +const PostPreview = ({ entry, widgetFor }) => { + return h( + 'div', + {}, + h('div', { className: 'cover' }, h('h1', {}, entry.data.title), widgetFor('image')), + h('p', {}, h('small', {}, 'Written ' + entry.data.date)), + h('div', { className: 'text' }, widgetFor('body')), + ); +}; + +const PostDateFieldPreview = ({ value }) => { + const date = new Date(value); + + const month = date.getMonth() + 1; + const day = date.getDate(); + + return h( + 'div', + {}, + `${date.getFullYear()}-${month < 10 ? `0${month}` : month}-${day < 10 ? `0${day}` : day}`, + ); +}; + +const PostDraftFieldPreview = ({ value }) => { + return h( + 'div', + { + style: { + backgroundColor: value === true ? 'rgb(37 99 235)' : 'rgb(22 163 74)', + color: 'white', + border: 'none', + padding: '2px 6px', + textAlign: 'center', + textDecoration: 'none', + display: 'inline-block', + cursor: 'pointer', + borderRadius: '4px', + fontSize: '14px', + }, + }, + value === true ? 'Draft' : 'Published', + ); +}; + +const GeneralPreview = ({ widgetsFor, entry, collection }) => { + const title = entry.data.site_title; + const posts = entry.data.posts; + const thumb = posts && posts.thumb; + + const thumbUrl = useMediaAsset(thumb, collection, undefined, entry); + + return h( + 'div', + {}, + h('h1', {}, title), + h( + 'dl', + {}, + h('dt', {}, 'Posts on Frontpage'), + h('dd', {}, widgetsFor('posts').widgets.front_limit ?? 0), + + h('dt', {}, 'Default Author'), + h('dd', {}, widgetsFor('posts').data?.author ?? 'None'), + + h('dt', {}, 'Default Thumbnail'), + h('dd', {}, thumb && h('img', { src: thumbUrl })), + ), + ); +}; + +const AuthorsPreview = ({ widgetsFor }) => { + return h( + 'div', + {}, + h('h1', {}, 'Authors'), + widgetsFor('authors').map(function (author, index) { + return h( + 'div', + { key: index }, + h('hr', {}), + h('strong', {}, author.data.name), + author.widgets.description, + ); + }), + ); +}; + +const RelationKitchenSinkPostPreview = ({ fieldsMetaData }) => { + // When a post is selected from the relation field, all of it's data + // will be available in the field's metadata nested under the collection + // name, and then further nested under the value specified in `value_field`. + // In this case, the post would be nested under "posts" and then under + // the title of the selected post, since our `value_field` in the config + // is "title". + const post = fieldsMetaData && fieldsMetaData.posts.value; + const style = { border: '2px solid #ccc', borderRadius: '8px', padding: '20px' }; + return post + ? h( + 'div', + { style: style }, + h('h2', {}, 'Related Post'), + h('h3', {}, post.title), + h('img', { src: post.image }), + h('p', {}, (post.body ?? '').slice(0, 100) + '...'), + ) + : null; +}; + +const CustomPage = () => { + return h('div', {}, 'I am a custom page!'); +}; + +CMS.registerPreviewTemplate('posts', PostPreview); +CMS.registerFieldPreview('posts', 'date', PostDateFieldPreview); +CMS.registerFieldPreview('posts', 'draft', PostDraftFieldPreview); +CMS.registerPreviewTemplate('general', GeneralPreview); +CMS.registerPreviewTemplate('authors', AuthorsPreview); +// Pass the name of a registered control to reuse with a new widget preview. +CMS.registerWidget('relationKitchenSinkPost', 'relation', RelationKitchenSinkPostPreview); +CMS.registerAdditionalLink({ + id: 'example', + title: 'Example.com', + data: 'https://example.com', + options: { + icon: 'page', + }, +}); +CMS.registerAdditionalLink({ + id: 'custom-page', + title: 'Custom Page', + data: CustomPage, + options: { + icon: 'page', + }, +}); + +CMS.registerShortcode('youtube', { + label: 'YouTube', + openTag: '[', + closeTag: ']', + separator: '|', + toProps: args => { + if (args.length > 0) { + return { src: args[0] }; + } + + return { src: '' }; + }, + toArgs: ({ src }) => { + return [src]; + }, + control: ({ src, onChange, theme }) => { + return h('span', {}, [ + h('input', { + key: 'control-input', + value: src, + onChange: event => { + onChange({ src: event.target.value }); + }, + style: { + width: '100%', + backgroundColor: theme === 'dark' ? 'rgb(30, 41, 59)' : 'white', + color: theme === 'dark' ? 'white' : 'black', + padding: '4px 8px', + }, + }), + h( + 'iframe', + { + key: 'control-preview', + width: '100%', + height: '315', + src: `https://www.youtube.com/embed/${src}`, + }, + '', + ), + ]); + }, + preview: ({ src }) => { + return h( + 'span', + {}, + h( + 'iframe', + { + width: '420', + height: '315', + src: `https://www.youtube.com/embed/${src}`, + }, + '', + ), + ); + }, +}); diff --git a/packages/core/package.json b/packages/core/package.json index 2c74b6a7..e133b8eb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -146,6 +146,7 @@ "react-router-dom": "6.10.0", "react-scroll-sync": "0.11.0", "react-topbar-progress-indicator": "4.1.1", + "react-virtual": "2.10.4", "react-virtualized-auto-sizer": "1.0.15", "react-waypoint": "10.3.0", "react-window": "1.8.9", diff --git a/packages/core/src/actions/entries.ts b/packages/core/src/actions/entries.ts index 2445dbd4..203ba1d5 100644 --- a/packages/core/src/actions/entries.ts +++ b/packages/core/src/actions/entries.ts @@ -633,8 +633,7 @@ export async function tryLoadEntry(state: RootState, collection: Collection, slu } const backend = currentBackend(configState.config); - const loadedEntry = await backend.getEntry(state, collection, slug); - return loadedEntry; + return backend.getEntry(state, collection, slug); } interface AppendAction { diff --git a/packages/core/src/components/MainView.tsx b/packages/core/src/components/MainView.tsx index 4b44e4ea..55851dbb 100644 --- a/packages/core/src/components/MainView.tsx +++ b/packages/core/src/components/MainView.tsx @@ -46,6 +46,7 @@ const MainView = ({
{showLeftNav ? : null}
= ({ const breadcrumbs = useBreadcrumbs(collection, filterTerm); return ( - + +
{isSearchResults ? ( <> @@ -226,7 +226,7 @@ const CollectionView = ({ )}
{collectionDescription ? ( -
+
{collectionDescription}
) : null} diff --git a/packages/core/src/components/collections/entries/Entries.tsx b/packages/core/src/components/collections/entries/Entries.tsx index 78227318..92318cba 100644 --- a/packages/core/src/components/collections/entries/Entries.tsx +++ b/packages/core/src/components/collections/entries/Entries.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { translate } from 'react-polyglot'; import Loader from '@staticcms/core/components/common/progress/Loader'; @@ -37,11 +37,14 @@ const Entries = ({ page, ...otherProps }: TranslatedProps) => { - const loadingMessages = [ - t('collection.entries.loadingEntries'), - t('collection.entries.cachingEntries'), - t('collection.entries.longerLoading'), - ]; + const loadingMessages = useMemo( + () => [ + t('collection.entries.loadingEntries'), + t('collection.entries.cachingEntries'), + t('collection.entries.longerLoading'), + ], + [t], + ); if (isFetching && page === undefined) { return {loadingMessages}; @@ -49,33 +52,28 @@ const Entries = ({ const hasEntries = (entries && entries.length > 0) || cursor?.actions?.has('append_next'); if (hasEntries) { - return ( - <> - {'collection' in otherProps ? ( - - ) : ( - - )} - {isFetching && page !== undefined && entries.length > 0 ? ( -
{t('collection.entries.loadingEntries')}
- ) : null} - + return 'collection' in otherProps ? ( + 0} + /> + ) : ( + 0} + /> ); } diff --git a/packages/core/src/components/collections/entries/EntryCard.tsx b/packages/core/src/components/collections/entries/EntryCard.tsx index 3b1a03ff..075a60f6 100644 --- a/packages/core/src/components/collections/entries/EntryCard.tsx +++ b/packages/core/src/components/collections/entries/EntryCard.tsx @@ -1,10 +1,7 @@ import { Info as InfoIcon } from '@styled-icons/material-outlined/Info'; -import get from 'lodash/get'; import React, { useEffect, useMemo, useState } from 'react'; -import { VIEW_STYLE_LIST } from '@staticcms/core/constants/views'; -import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset'; -import { getFieldPreview, getPreviewCard } from '@staticcms/core/lib/registry'; +import { getPreviewCard } from '@staticcms/core/lib/registry'; import { getEntryBackupKey } from '@staticcms/core/lib/util/backup.util'; import { selectEntryCollectionTitle, @@ -12,7 +9,6 @@ import { selectTemplateName, } from '@staticcms/core/lib/util/collection.util'; import localForage from '@staticcms/core/lib/util/localForage'; -import { isNullish } from '@staticcms/core/lib/util/null.util'; import { selectConfig } from '@staticcms/core/reducers/selectors/config'; import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI'; import { useAppSelector } from '@staticcms/core/store/hooks'; @@ -20,17 +16,13 @@ import Card from '../../common/card/Card'; import CardActionArea from '../../common/card/CardActionArea'; import CardContent from '../../common/card/CardContent'; import CardMedia from '../../common/card/CardMedia'; -import TableCell from '../../common/table/TableCell'; -import TableRow from '../../common/table/TableRow'; import useWidgetsFor from '../../common/widget/useWidgetsFor'; -import type { ViewStyle } from '@staticcms/core/constants/views'; import type { BackupEntry, Collection, Entry, FileOrImageField, - MediaField, TranslatedProps, } from '@staticcms/core/interface'; import type { FC } from 'react'; @@ -39,18 +31,12 @@ export interface EntryCardProps { entry: Entry; imageFieldName?: string | null | undefined; collection: Collection; - collectionLabel?: string; - viewStyle: ViewStyle; - summaryFields: string[]; } const EntryCard: FC> = ({ collection, entry, - collectionLabel, - viewStyle = VIEW_STYLE_LIST, imageFieldName, - summaryFields, t, }) => { const entryData = entry.data; @@ -83,7 +69,6 @@ const EntryCard: FC> = ({ const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]); const fields = selectFields(collection, entry.slug); - const imageUrl = useMediaAsset(image, collection as Collection, imageField, entry); const config = useAppSelector(selectConfig); @@ -123,66 +108,6 @@ const EntryCard: FC> = ({ }; }, [collection.name, entry.slug]); - if (viewStyle === VIEW_STYLE_LIST) { - return ( - - {collectionLabel ? ( - - {collectionLabel} - - ) : null} - {summaryFields.map(fieldName => { - if (fieldName === 'summary') { - return ( - - {summary} - - ); - } - - const field = fields.find(f => f.name === fieldName); - const value = get(entry.data, fieldName); - const FieldPreviewComponent = getFieldPreview(templateName, fieldName); - - return ( - - {field && FieldPreviewComponent ? ( - - ) : isNullish(value) ? ( - '' - ) : ( - String(value) - )} - - ); - })} - - {hasLocalBackup ? ( - - ) : null} - - - ); - } - if (PreviewCardComponent) { return ( @@ -202,9 +127,9 @@ const EntryCard: FC> = ({ } return ( - + - {image && imageField ? : null} + {image && imageField ? : null}
{summary}
diff --git a/packages/core/src/components/collections/entries/EntryListing.tsx b/packages/core/src/components/collections/entries/EntryListing.tsx index c8c83d41..b71a221a 100644 --- a/packages/core/src/components/collections/entries/EntryListing.tsx +++ b/packages/core/src/components/collections/entries/EntryListing.tsx @@ -1,15 +1,16 @@ import React, { useCallback, useMemo } from 'react'; import { translate } from 'react-polyglot'; -import { Waypoint } from 'react-waypoint'; +import { VIEW_STYLE_TABLE } from '@staticcms/core/constants/views'; import { selectFields, selectInferredField } from '@staticcms/core/lib/util/collection.util'; import { toTitleCaseFromKey } from '@staticcms/core/lib/util/string.util'; -import Table from '../../common/table/Table'; -import EntryCard from './EntryCard'; +import EntryListingGrid from './EntryListingGrid'; +import EntryListingTable from './EntryListingTable'; import type { ViewStyle } from '@staticcms/core/constants/views'; import type { Collection, + CollectionEntryData, Collections, Entry, Field, @@ -22,6 +23,7 @@ export interface BaseEntryListingProps { entries: Entry[]; viewStyle: ViewStyle; cursor?: Cursor; + isLoadingEntries: boolean; handleCursorActions?: (action: string) => void; page?: number; } @@ -40,9 +42,9 @@ export type EntryListingProps = const EntryListing: FC> = ({ entries, - page, cursor, viewStyle, + isLoadingEntries, handleCursorActions, t, ...otherProps @@ -93,44 +95,43 @@ const EntryListing: FC> = ({ [otherProps], ); - const renderedCards = useMemo(() => { + const entryData: CollectionEntryData[] = useMemo(() => { if ('collection' in otherProps) { const inferredFields = inferFields(otherProps.collection); - return entries.map(entry => ( - - )); + + return entries.map(entry => ({ + collection: otherProps.collection, + imageFieldName: inferredFields.imageField, + viewStyle, + entry, + key: entry.slug, + summaryFields, + })); } - return entries.map(entry => { - const collectionName = entry.collection; - const collection = Object.values(otherProps.collections).find( - coll => coll.name === collectionName, - ); + return entries + .map(entry => { + const collectionName = entry.collection; + const collection = Object.values(otherProps.collections).find( + coll => coll.name === collectionName, + ); - const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined; - const inferredFields = inferFields(collection); - return collection ? ( - - ) : null; - }); - }, [entries, inferFields, isSingleCollectionInList, otherProps, summaryFields, t, viewStyle]); + const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined; + const inferredFields = inferFields(collection); + return collection + ? { + collection, + entry, + imageFieldName: inferredFields.imageField, + viewStyle, + collectionLabel, + key: entry.slug, + summaryFields, + } + : null; + }) + .filter(e => e) as CollectionEntryData[]; + }, [entries, inferFields, isSingleCollectionInList, otherProps, summaryFields, viewStyle]); const summaryFieldHeaders = useMemo(() => { if ('collection' in otherProps) { @@ -149,38 +150,30 @@ const EntryListing: FC> = ({ return []; }, [otherProps, summaryFields]); - if (viewStyle === 'VIEW_STYLE_LIST') { + if (viewStyle === VIEW_STYLE_TABLE) { return ( - <> - - {renderedCards} -
- {hasMore && handleLoadMore && } - + ); } return ( -
- {renderedCards} - {hasMore && handleLoadMore && } -
+ ); }; diff --git a/packages/core/src/components/collections/entries/EntryListingCardGrid.tsx b/packages/core/src/components/collections/entries/EntryListingCardGrid.tsx new file mode 100644 index 00000000..22c3d11f --- /dev/null +++ b/packages/core/src/components/collections/entries/EntryListingCardGrid.tsx @@ -0,0 +1,160 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { VariableSizeGrid as Grid } from 'react-window'; + +import { + MEDIA_CARD_HEIGHT, + MEDIA_CARD_MARGIN, + MEDIA_CARD_WIDTH, + MEDIA_LIBRARY_PADDING, +} from '@staticcms/core/constants/mediaLibrary'; +import classNames from '@staticcms/core/lib/util/classNames.util'; +import EntryCard from './EntryCard'; + +import type { CollectionEntryData } from '@staticcms/core/interface'; +import type { FC } from 'react'; +import type { t } from 'react-polyglot'; +import type { GridChildComponentProps } from 'react-window'; + +export interface EntryListingCardGridProps { + scrollContainerRef: React.MutableRefObject; + entryData: CollectionEntryData[]; + onScroll: () => void; + t: t; +} + +export interface CardGridItemData { + columnCount: number; + entryData: CollectionEntryData[]; + t: t; +} + +const CardWrapper = ({ + rowIndex, + columnIndex, + style, + data: { columnCount, entryData, t }, +}: GridChildComponentProps) => { + const left = useMemo( + () => + parseFloat( + `${ + typeof style.left === 'number' + ? style.left ?? MEDIA_CARD_MARGIN * columnIndex + : style.left + }`, + ), + [columnIndex, style.left], + ); + + const top = useMemo( + () => parseFloat(`${typeof style.top === 'number' ? style.top ?? 0 : style.top}`) + 4, + [style.top], + ); + + const index = rowIndex * columnCount + columnIndex; + if (index >= entryData.length) { + return null; + } + const data = entryData[index]; + + return ( +
+ +
+ ); +}; + +const EntryListingCardGrid: FC = ({ + entryData, + scrollContainerRef, + onScroll, + t, +}) => { + const [version, setVersion] = useState(0); + + const handleResize = useCallback(() => { + setVersion(oldVersion => oldVersion + 1); + }, []); + + return ( +
+ + {({ height = 0, width = 0 }) => { + const columnWidthWithGutter = MEDIA_CARD_WIDTH + MEDIA_CARD_MARGIN; + const rowHeightWithGutter = MEDIA_CARD_HEIGHT + MEDIA_CARD_MARGIN; + const columnCount = Math.floor(width / columnWidthWithGutter); + const nonGutterSpace = (width - MEDIA_CARD_MARGIN * columnCount) / width; + const columnWidth = (1 / columnCount) * nonGutterSpace; + + const rowCount = Math.ceil(entryData.length / columnCount); + + return ( +
+ + index + 1 === columnCount + ? width * columnWidth + : width * columnWidth + MEDIA_CARD_MARGIN + } + rowCount={rowCount} + rowHeight={() => rowHeightWithGutter} + width={width} + height={height - MEDIA_LIBRARY_PADDING} + itemData={ + { + entryData, + columnCount, + t, + } as CardGridItemData + } + outerRef={scrollContainerRef} + onScroll={onScroll} + className={classNames( + ` + overflow-hidden + overflow-y-auto + styled-scrollbars + `, + )} + style={{ position: 'unset' }} + > + {CardWrapper} + +
+ ); + }} +
+
+ ); +}; + +export default EntryListingCardGrid; diff --git a/packages/core/src/components/collections/entries/EntryListingGrid.tsx b/packages/core/src/components/collections/entries/EntryListingGrid.tsx new file mode 100644 index 00000000..a4060a27 --- /dev/null +++ b/packages/core/src/components/collections/entries/EntryListingGrid.tsx @@ -0,0 +1,90 @@ +import React, { useCallback, useEffect, useRef } from 'react'; + +import { isNotNullish } from '@staticcms/core/lib/util/null.util'; +import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI'; +import { useAppSelector } from '@staticcms/core/store/hooks'; +import EntryListingCardGrid from './EntryListingCardGrid'; + +import type { CollectionEntryData } from '@staticcms/core/interface'; +import type { FC } from 'react'; +import type { t } from 'react-polyglot'; + +export interface EntryListingGridProps { + entryData: CollectionEntryData[]; + canLoadMore?: boolean; + isLoadingEntries: boolean; + onLoadMore: () => void; + t: t; +} + +const EntryListingGrid: FC = ({ + entryData, + canLoadMore, + isLoadingEntries, + onLoadMore, + t, +}) => { + const gridContainerRef = useRef(null); + + const isFetching = useAppSelector(selectIsFetching); + + const fetchMoreOnBottomReached = useCallback( + (scrollHeight?: number, scrollTop?: number, clientHeight?: number) => { + if (isNotNullish(scrollHeight) && isNotNullish(scrollTop) && isNotNullish(clientHeight)) { + //once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any + if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && canLoadMore) { + onLoadMore(); + } + } + }, + [isFetching, canLoadMore, onLoadMore], + ); + + const handleScroll = useCallback(() => { + const { scrollHeight, scrollTop, clientHeight } = gridContainerRef.current ?? {}; + + fetchMoreOnBottomReached(scrollHeight, scrollTop, clientHeight); + }, [fetchMoreOnBottomReached]); + + useEffect(() => { + const interval = setInterval(() => { + handleScroll(); + }, 100); + + return () => { + clearInterval(interval); + }; + }, [handleScroll]); + + return ( +
+
+ +
+ {isLoadingEntries ? ( +
+ {t('collection.entries.loadingEntries')} +
+ ) : null} +
+ ); +}; + +export default EntryListingGrid; diff --git a/packages/core/src/components/collections/entries/EntryListingTable.tsx b/packages/core/src/components/collections/entries/EntryListingTable.tsx new file mode 100644 index 00000000..06375bc5 --- /dev/null +++ b/packages/core/src/components/collections/entries/EntryListingTable.tsx @@ -0,0 +1,125 @@ +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useVirtual } from 'react-virtual'; + +import { isNotNullish } from '@staticcms/core/lib/util/null.util'; +import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI'; +import { useAppSelector } from '@staticcms/core/store/hooks'; +import Table from '../../common/table/Table'; +import EntryRow from './EntryRow'; + +import type { CollectionEntryData } from '@staticcms/core/interface'; +import type { FC } from 'react'; +import type { t } from 'react-polyglot'; + +export interface EntryListingTableProps { + isSingleCollectionInList: boolean; + entryData: CollectionEntryData[]; + summaryFieldHeaders: string[]; + canLoadMore: boolean; + isLoadingEntries: boolean; + loadNext: () => void; + t: t; +} + +const EntryListingTable: FC = ({ + isSingleCollectionInList, + entryData, + summaryFieldHeaders, + canLoadMore, + isLoadingEntries, + loadNext, + t, +}) => { + const isFetching = useAppSelector(selectIsFetching); + + const tableContainerRef = useRef(null); + + const { virtualItems: virtualRows, totalSize } = useVirtual({ + parentRef: tableContainerRef, + size: entryData.length, + overscan: 10, + }); + + const paddingTop = useMemo( + () => (virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0), + [virtualRows], + ); + const paddingBottom = useMemo( + () => + virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0, + [totalSize, virtualRows], + ); + + const fetchMoreOnBottomReached = useCallback( + (scrollHeight?: number, scrollTop?: number, clientHeight?: number) => { + if (isNotNullish(scrollHeight) && isNotNullish(scrollTop) && isNotNullish(clientHeight)) { + //once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any + if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && canLoadMore) { + loadNext(); + } + } + }, + [isFetching, canLoadMore, loadNext], + ); + + const { scrollHeight, scrollTop, clientHeight } = tableContainerRef.current ?? {}; + + useEffect(() => { + fetchMoreOnBottomReached(scrollHeight, scrollTop, clientHeight); + }, [clientHeight, fetchMoreOnBottomReached, scrollHeight, scrollTop]); + + return ( +
+
+ + {paddingTop > 0 && ( + + + )} + {virtualRows.map(virtualRow => { + const data = entryData[virtualRow.index]; + return ( + + ); + })} + {paddingBottom > 0 && ( + + + )} +
+
+
+
+ {isLoadingEntries ? ( +
+ {t('collection.entries.loadingEntries')} +
+ ) : null} +
+ ); +}; + +export default EntryListingTable; diff --git a/packages/core/src/components/collections/entries/EntryRow.tsx b/packages/core/src/components/collections/entries/EntryRow.tsx new file mode 100644 index 00000000..668c5197 --- /dev/null +++ b/packages/core/src/components/collections/entries/EntryRow.tsx @@ -0,0 +1,136 @@ +import { Info as InfoIcon } from '@styled-icons/material-outlined/Info'; +import get from 'lodash/get'; +import React, { useEffect, useMemo, useState } from 'react'; + +import { getFieldPreview } from '@staticcms/core/lib/registry'; +import { getEntryBackupKey } from '@staticcms/core/lib/util/backup.util'; +import { + selectEntryCollectionTitle, + selectFields, + selectTemplateName, +} from '@staticcms/core/lib/util/collection.util'; +import localForage from '@staticcms/core/lib/util/localForage'; +import { isNullish } from '@staticcms/core/lib/util/null.util'; +import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI'; +import { useAppSelector } from '@staticcms/core/store/hooks'; +import TableCell from '../../common/table/TableCell'; +import TableRow from '../../common/table/TableRow'; + +import type { BackupEntry, Collection, Entry, TranslatedProps } from '@staticcms/core/interface'; +import type { FC } from 'react'; + +export interface EntryRowProps { + entry: Entry; + collection: Collection; + collectionLabel?: string; + summaryFields: string[]; +} + +const EntryRow: FC> = ({ + collection, + entry, + collectionLabel, + summaryFields, + t, +}) => { + const path = useMemo( + () => `/collections/${collection.name}/entries/${entry.slug}`, + [collection.name, entry.slug], + ); + + const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]); + + const fields = selectFields(collection, entry.slug); + + const templateName = useMemo( + () => selectTemplateName(collection, entry.slug), + [collection, entry.slug], + ); + + const theme = useAppSelector(selectTheme); + + const [hasLocalBackup, setHasLocalBackup] = useState(false); + useEffect(() => { + let alive = true; + + const checkLocalBackup = async () => { + const key = getEntryBackupKey(collection.name, entry.slug); + const backup = await localForage.getItem(key); + + if (alive) { + setHasLocalBackup(Boolean(backup)); + } + }; + + checkLocalBackup(); + + // Check again after small delay to ensure we capture the draft just made from the editor + setTimeout(() => { + checkLocalBackup(); + }, 250); + + return () => { + alive = false; + }; + }, [collection.name, entry.slug]); + + return ( + + {collectionLabel ? ( + + {collectionLabel} + + ) : null} + {summaryFields.map(fieldName => { + if (fieldName === 'summary') { + return ( + + {summary} + + ); + } + + const field = fields.find(f => f.name === fieldName); + const value = get(entry.data, fieldName); + const FieldPreviewComponent = getFieldPreview(templateName, fieldName); + + return ( + + {field && FieldPreviewComponent ? ( + + ) : isNullish(value) ? ( + '' + ) : ( + String(value) + )} + + ); + })} + + {hasLocalBackup ? ( + + ) : null} + + + ); +}; + +export default EntryRow; diff --git a/packages/core/src/components/common/card/Card.tsx b/packages/core/src/components/common/card/Card.tsx index 41b8d809..802f71c3 100644 --- a/packages/core/src/components/common/card/Card.tsx +++ b/packages/core/src/components/common/card/Card.tsx @@ -20,6 +20,8 @@ const Card = ({ children, className }: CardProps) => { shadow-md dark:bg-slate-800 dark:border-gray-700/40 + flex + flex-col `, className, )} diff --git a/packages/core/src/components/common/card/CardActionArea.tsx b/packages/core/src/components/common/card/CardActionArea.tsx index 6df6f998..81e0f1c5 100644 --- a/packages/core/src/components/common/card/CardActionArea.tsx +++ b/packages/core/src/components/common/card/CardActionArea.tsx @@ -17,6 +17,8 @@ const CardActionArea = ({ to, children }: CardActionAreaProps) => { w-full relative flex + flex-col + rounded-lg justify-start hover:bg-gray-200 dark:hover:bg-slate-700/70 diff --git a/packages/core/src/components/common/card/CardMedia.tsx b/packages/core/src/components/common/card/CardMedia.tsx index 55856d80..96175efe 100644 --- a/packages/core/src/components/common/card/CardMedia.tsx +++ b/packages/core/src/components/common/card/CardMedia.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import Image from '../image/Image'; + interface CardMediaProps { image: string; width?: string | number; @@ -9,7 +11,7 @@ interface CardMediaProps { const CardMedia = ({ image, width, height, alt = '' }: CardMediaProps) => { return ( - { src?: string; alt?: string; className?: string; + style?: CSSProperties; collection?: Collection; field?: MediaField; 'data-testid'?: string; @@ -22,6 +24,7 @@ const Image = ({ src, alt, className, + style, collection, field, 'data-testid': dataTestId, @@ -52,6 +55,7 @@ const Image = ({ alt={alt} data-testid={dataTestId ?? 'image'} className={classNames('object-cover max-w-full overflow-hidden', className)} + style={style} /> ); }; diff --git a/packages/core/src/components/common/table/Table.tsx b/packages/core/src/components/common/table/Table.tsx index 63340341..fac6ba6d 100644 --- a/packages/core/src/components/common/table/Table.tsx +++ b/packages/core/src/components/common/table/Table.tsx @@ -11,12 +11,18 @@ interface TableCellProps { const TableCell = ({ columns, children }: TableCellProps) => { return ( -
- - +
+
+ {columns.map((column, index) => ( - {column} + + {column} + ))} diff --git a/packages/core/src/components/common/table/TableHeaderCell.tsx b/packages/core/src/components/common/table/TableHeaderCell.tsx index a902cf8e..417823a5 100644 --- a/packages/core/src/components/common/table/TableHeaderCell.tsx +++ b/packages/core/src/components/common/table/TableHeaderCell.tsx @@ -1,15 +1,46 @@ import React from 'react'; +import classNames from '@staticcms/core/lib/util/classNames.util'; +import { isEmpty } from '@staticcms/core/lib/util/string.util'; + import type { ReactNode } from 'react'; interface TableHeaderCellProps { children: ReactNode; + isFirst: boolean; + isLast: boolean; } -const TableHeaderCell = ({ children }: TableHeaderCellProps) => { +const TableHeaderCell = ({ children, isFirst, isLast }: TableHeaderCellProps) => { return ( - ); }; diff --git a/packages/core/src/components/common/view-style/ViewStyleControl.tsx b/packages/core/src/components/common/view-style/ViewStyleControl.tsx index d3ba589c..f554bfc8 100644 --- a/packages/core/src/components/common/view-style/ViewStyleControl.tsx +++ b/packages/core/src/components/common/view-style/ViewStyleControl.tsx @@ -2,7 +2,7 @@ import { Grid as GridIcon } from '@styled-icons/bootstrap/Grid'; import { TableRows as TableRowsIcon } from '@styled-icons/material-rounded/TableRows'; import React from 'react'; -import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '@staticcms/core/constants/views'; +import { VIEW_STYLE_GRID, VIEW_STYLE_TABLE } from '@staticcms/core/constants/views'; import classNames from '@staticcms/core/lib/util/classNames.util'; import IconButton from '../button/IconButton'; @@ -18,9 +18,9 @@ const ViewStyleControl = ({ viewStyle, onChangeViewStyle }: ViewStyleControlPros
onChangeViewStyle(VIEW_STYLE_LIST)} + onClick={() => onChangeViewStyle(VIEW_STYLE_TABLE)} > diff --git a/packages/core/src/components/entry-editor/EditorToolbar.tsx b/packages/core/src/components/entry-editor/EditorToolbar.tsx index b7d79af7..3277fad3 100644 --- a/packages/core/src/components/entry-editor/EditorToolbar.tsx +++ b/packages/core/src/components/entry-editor/EditorToolbar.tsx @@ -10,13 +10,14 @@ import { Publish as PublishIcon } from '@styled-icons/material/Publish'; import React, { useCallback, useMemo } from 'react'; import { translate } from 'react-polyglot'; +import { deleteLocalBackup, loadEntry } from '@staticcms/core/actions/entries'; import { selectAllowDeletion } from '@staticcms/core/lib/util/collection.util'; +import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI'; +import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; +import confirm from '../common/confirm/Confirm'; import Menu from '../common/menu/Menu'; import MenuGroup from '../common/menu/MenuGroup'; import MenuItemButton from '../common/menu/MenuItemButton'; -import confirm from '../common/confirm/Confirm'; -import { useAppDispatch } from '@staticcms/core/store/hooks'; -import { deleteLocalBackup, loadEntry } from '@staticcms/core/actions/entries'; import type { Collection, EditorPersistOptions, TranslatedProps } from '@staticcms/core/interface'; import type { FC, MouseEventHandler } from 'react'; @@ -73,6 +74,7 @@ const EditorToolbar = ({ ); const canDelete = useMemo(() => selectAllowDeletion(collection), [collection]); const isPublished = useMemo(() => !isNewEntry && !hasChanged, [hasChanged, isNewEntry]); + const isLoading = useAppSelector(selectIsFetching); const dispatch = useAppDispatch(); @@ -185,7 +187,7 @@ const EditorToolbar = ({ <> @@ -193,7 +195,7 @@ const EditorToolbar = ({ {menuItems.map((group, index) => ( {group} @@ -229,14 +231,15 @@ const EditorToolbar = ({ [ showI18nToggle, showPreviewToggle, + canDelete, toggleI18n, i18nActive, t, togglePreview, + isLoading, previewActive, toggleScrollSync, scrollSyncActive, - canDelete, onDelete, isPublished, menuItems, diff --git a/packages/core/src/constants/configSchema.tsx b/packages/core/src/constants/configSchema.tsx index 6f990bbf..6eca3ee1 100644 --- a/packages/core/src/constants/configSchema.tsx +++ b/packages/core/src/constants/configSchema.tsx @@ -1,13 +1,12 @@ import AJV from 'ajv'; -import select from 'ajv-keywords/dist/keywords/select'; -import uniqueItemProperties from 'ajv-keywords/dist/keywords/uniqueItemProperties'; +import ajvErrors from 'ajv-errors'; import instanceOf from 'ajv-keywords/dist/keywords/instanceof'; import prohibited from 'ajv-keywords/dist/keywords/prohibited'; -import ajvErrors from 'ajv-errors'; +import select from 'ajv-keywords/dist/keywords/select'; +import uniqueItemProperties from 'ajv-keywords/dist/keywords/uniqueItemProperties'; import { v4 as uuid } from 'uuid'; -import { formatExtensions, frontmatterFormats, extensionFormatters } from '../formats/formats'; -import { getWidgets } from '../lib/registry'; +import { extensionFormatters, formatExtensions, frontmatterFormats } from '../formats/formats'; import { I18N_FIELD_DUPLICATE, I18N_FIELD_NONE, @@ -16,6 +15,7 @@ import { I18N_STRUCTURE_MULTIPLE_FOLDERS, I18N_STRUCTURE_SINGLE_FILE, } from '../lib/i18n'; +import { getWidgets } from '../lib/registry'; import type { ErrorObject } from 'ajv'; import type { Config } from '../interface'; diff --git a/packages/core/src/constants/views.ts b/packages/core/src/constants/views.ts index d2b94ce8..bf9cd97b 100644 --- a/packages/core/src/constants/views.ts +++ b/packages/core/src/constants/views.ts @@ -1,4 +1,4 @@ -export const VIEW_STYLE_LIST = 'VIEW_STYLE_LIST'; -export const VIEW_STYLE_GRID = 'VIEW_STYLE_GRID'; +export const VIEW_STYLE_TABLE = 'table'; +export const VIEW_STYLE_GRID = 'grid'; -export type ViewStyle = typeof VIEW_STYLE_LIST | typeof VIEW_STYLE_GRID; +export type ViewStyle = typeof VIEW_STYLE_TABLE | typeof VIEW_STYLE_GRID; diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index 43a32636..e6311b15 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -37,6 +37,7 @@ import type { STRIKETHROUGH_TOOLBAR_BUTTON, UNORDERED_LIST_TOOLBAR_BUTTON, } from './constants/toolbar_buttons'; +import type { ViewStyle } from './constants/views'; import type { formatExtensions } from './formats/formats'; import type { I18N_FIELD_DUPLICATE, @@ -1087,3 +1088,13 @@ export interface BackupEntry { mediaFiles: MediaFile[]; i18n?: Record; } + +export interface CollectionEntryData { + collection: Collection; + imageFieldName: string | null | undefined; + viewStyle: ViewStyle; + entry: Entry; + key: string; + summaryFields: string[]; + collectionLabel?: string; +} diff --git a/packages/core/src/reducers/entries.ts b/packages/core/src/reducers/entries.ts index 03c7feb7..d6696b1b 100644 --- a/packages/core/src/reducers/entries.ts +++ b/packages/core/src/reducers/entries.ts @@ -22,7 +22,7 @@ import { SORT_ENTRIES_REQUEST, SORT_ENTRIES_SUCCESS, } from '../constants'; -import { VIEW_STYLE_LIST } from '../constants/views'; +import { VIEW_STYLE_TABLE } from '../constants/views'; import { set } from '../lib/util/object.util'; import type { EntriesAction } from '../actions/entries'; @@ -97,8 +97,8 @@ const loadViewStyle = once(() => { return viewStyle; } - localStorage.setItem(viewStyleKey, VIEW_STYLE_LIST); - return VIEW_STYLE_LIST; + localStorage.setItem(viewStyleKey, VIEW_STYLE_TABLE); + return VIEW_STYLE_TABLE; }); function clearViewStyle() { diff --git a/tailwind.base.config.js b/tailwind.base.config.js index d8f58b34..3b36ad16 100644 --- a/tailwind.base.config.js +++ b/tailwind.base.config.js @@ -10,6 +10,7 @@ module.exports = { "media-card-image": "196px", "image-card": "120px", input: "24px", + "table-full": "calc(100% - 40px)" }, minHeight: { 8: "2rem", diff --git a/yarn.lock b/yarn.lock index b12b8f1d..3c58b45a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3594,6 +3594,11 @@ dependencies: "@babel/runtime" "^7.13.10" +"@reach/observe-rect@^1.1.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2" + integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ== + "@react-dnd/asap@^5.0.1": version "5.0.2" resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488" @@ -16401,6 +16406,13 @@ react-use@^17.3.2: ts-easing "^0.2.0" tslib "^2.1.0" +react-virtual@2.10.4: + version "2.10.4" + resolved "https://registry.yarnpkg.com/react-virtual/-/react-virtual-2.10.4.tgz#08712f0acd79d7d6f7c4726f05651a13b24d8704" + integrity sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ== + dependencies: + "@reach/observe-rect" "^1.1.0" + react-virtualized-auto-sizer@1.0.15: version "1.0.15" resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.15.tgz#84558bcab61a625d13ec37876639bb09c5a3ec0b"
- {children} + +
+ {typeof children === 'string' && isEmpty(children) ? <>  : children} +