diff --git a/packages/core/dev-test/config.yml b/packages/core/dev-test/config.yml index bbcb5b6a..a74e1cbb 100644 --- a/packages/core/dev-test/config.yml +++ b/packages/core/dev-test/config.yml @@ -327,6 +327,34 @@ collections: - label: Description name: description widget: text + - name: string_list + label: String List + widget: list + fields: + - label: Tag + name: tag + widget: string + - name: number_list + label: Number List + widget: list + default: + - 5 + - 13 + - 2 + fields: + - label: Value + name: value + widget: number + - name: boolean_list + label: Boolean List + widget: list + default: + - false + - true + fields: + - label: Active + name: active + widget: boolean - name: typed_list label: Typed List widget: list diff --git a/packages/core/package.json b/packages/core/package.json index e1e71f9a..bff103cf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -180,8 +180,10 @@ "@emotion/jest": "11.10.5", "@pmmmwh/react-refresh-webpack-plugin": "0.5.10", "@simbathesailor/use-what-changed": "2.0.0", + "@testing-library/dom": "8.19.1", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "13.4.0", + "@testing-library/user-event": "14.4.3", "@types/common-tags": "1.8.1", "@types/create-react-class": "15.6.3", "@types/fs-extra": "11.0.1", diff --git a/packages/core/src/__mocks__/array-move.ts b/packages/core/src/__mocks__/array-move.ts new file mode 100644 index 00000000..0bb2acf5 --- /dev/null +++ b/packages/core/src/__mocks__/array-move.ts @@ -0,0 +1 @@ +export default jest.fn(); diff --git a/packages/core/src/__mocks__/history.ts b/packages/core/src/__mocks__/history.ts new file mode 100644 index 00000000..ee6e935c --- /dev/null +++ b/packages/core/src/__mocks__/history.ts @@ -0,0 +1,2 @@ +/* eslint-disable import/prefer-default-export */ +export const createHashHistory = jest.fn(); diff --git a/packages/core/src/actions/auth.ts b/packages/core/src/actions/auth.ts index 1d003d3b..6240b0f5 100644 --- a/packages/core/src/actions/auth.ts +++ b/packages/core/src/actions/auth.ts @@ -1,4 +1,5 @@ import { currentBackend } from '../backend'; +import { AUTH_FAILURE, AUTH_REQUEST, AUTH_REQUEST_DONE, AUTH_SUCCESS, LOGOUT } from '../constants'; import { addSnackbar } from '../store/slices/snackbars'; import type { AnyAction } from 'redux'; @@ -6,12 +7,6 @@ import type { ThunkDispatch } from 'redux-thunk'; import type { Credentials, User } from '../interface'; import type { RootState } from '../store'; -export const AUTH_REQUEST = 'AUTH_REQUEST'; -export const AUTH_SUCCESS = 'AUTH_SUCCESS'; -export const AUTH_FAILURE = 'AUTH_FAILURE'; -export const AUTH_REQUEST_DONE = 'AUTH_REQUEST_DONE'; -export const LOGOUT = 'LOGOUT'; - export function authenticating() { return { type: AUTH_REQUEST, diff --git a/packages/core/src/actions/config.ts b/packages/core/src/actions/config.ts index 0907c049..2a4093c8 100644 --- a/packages/core/src/actions/config.ts +++ b/packages/core/src/actions/config.ts @@ -5,6 +5,7 @@ import trimStart from 'lodash/trimStart'; import yaml from 'yaml'; import { resolveBackend } from '../backend'; +import { CONFIG_FAILURE, CONFIG_REQUEST, CONFIG_SUCCESS } from '../constants'; import validateConfig from '../constants/configSchema'; import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n'; import { selectDefaultSortableFields } from '../lib/util/collection.util'; @@ -23,10 +24,6 @@ import type { } from '../interface'; import type { RootState } from '../store'; -export const CONFIG_REQUEST = 'CONFIG_REQUEST'; -export const CONFIG_SUCCESS = 'CONFIG_SUCCESS'; -export const CONFIG_FAILURE = 'CONFIG_FAILURE'; - function isObjectField(field: Field): field is ObjectField { return 'fields' in (field as ObjectField); } @@ -125,7 +122,7 @@ function throwOnMissingDefaultLocale(i18n?: I18nInfo) { } export function applyDefaults(originalConfig: Config) { - return produce(originalConfig, config => { + return produce(originalConfig, (config: Config) => { config.slug = config.slug || {}; config.collections = config.collections || []; diff --git a/packages/core/src/actions/entries.ts b/packages/core/src/actions/entries.ts index df424670..091dab0c 100644 --- a/packages/core/src/actions/entries.ts +++ b/packages/core/src/actions/entries.ts @@ -1,15 +1,53 @@ import isEqual from 'lodash/isEqual'; import { currentBackend } from '../backend'; -import { SORT_DIRECTION_ASCENDING } from '../constants'; +import { + ADD_DRAFT_ENTRY_MEDIA_FILE, + CHANGE_VIEW_STYLE, + DRAFT_CHANGE_FIELD, + DRAFT_CREATE_DUPLICATE_FROM_ENTRY, + DRAFT_CREATE_EMPTY, + DRAFT_CREATE_FROM_ENTRY, + DRAFT_CREATE_FROM_LOCAL_BACKUP, + DRAFT_DISCARD, + DRAFT_LOCAL_BACKUP_DELETE, + DRAFT_LOCAL_BACKUP_RETRIEVED, + DRAFT_VALIDATION_ERRORS, + ENTRIES_FAILURE, + ENTRIES_REQUEST, + ENTRIES_SUCCESS, + ENTRY_DELETE_FAILURE, + ENTRY_DELETE_REQUEST, + ENTRY_DELETE_SUCCESS, + ENTRY_FAILURE, + ENTRY_PERSIST_FAILURE, + ENTRY_PERSIST_REQUEST, + ENTRY_PERSIST_SUCCESS, + ENTRY_REQUEST, + ENTRY_SUCCESS, + FILTER_ENTRIES_FAILURE, + FILTER_ENTRIES_REQUEST, + FILTER_ENTRIES_SUCCESS, + GROUP_ENTRIES_FAILURE, + GROUP_ENTRIES_REQUEST, + GROUP_ENTRIES_SUCCESS, + REMOVE_DRAFT_ENTRY_MEDIA_FILE, + SORT_DIRECTION_ASCENDING, + SORT_ENTRIES_FAILURE, + SORT_ENTRIES_REQUEST, + SORT_ENTRIES_SUCCESS, +} from '../constants'; import ValidationErrorTypes from '../constants/validationErrorTypes'; import { duplicateDefaultI18nFields, hasI18n, I18N_FIELD, serializeI18n } from '../lib/i18n'; import { serializeValues } from '../lib/serializeEntryValues'; import { Cursor } from '../lib/util'; import { selectFields, updateFieldByKey } from '../lib/util/collection.util'; -import { selectPublishedSlugs } from '../reducers'; -import { selectCollectionEntriesCursor } from '../reducers/cursors'; -import { selectEntriesSortFields, selectIsFetching } from '../reducers/entries'; +import { selectCollectionEntriesCursor } from '../reducers/selectors/cursors'; +import { + selectEntriesSortFields, + selectIsFetching, + selectPublishedSlugs, +} from '../reducers/selectors/entries'; import { navigateToEntry } from '../routing/history'; import { addSnackbar } from '../store/slices/snackbars'; import { createAssetProxy } from '../valueObjects/AssetProxy'; @@ -40,52 +78,6 @@ import type { import type { RootState } from '../store'; import type AssetProxy from '../valueObjects/AssetProxy'; -/* - * Constant Declarations - */ -export const ENTRY_REQUEST = 'ENTRY_REQUEST'; -export const ENTRY_SUCCESS = 'ENTRY_SUCCESS'; -export const ENTRY_FAILURE = 'ENTRY_FAILURE'; - -export const ENTRIES_REQUEST = 'ENTRIES_REQUEST'; -export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS'; -export const ENTRIES_FAILURE = 'ENTRIES_FAILURE'; - -export const SORT_ENTRIES_REQUEST = 'SORT_ENTRIES_REQUEST'; -export const SORT_ENTRIES_SUCCESS = 'SORT_ENTRIES_SUCCESS'; -export const SORT_ENTRIES_FAILURE = 'SORT_ENTRIES_FAILURE'; - -export const FILTER_ENTRIES_REQUEST = 'FILTER_ENTRIES_REQUEST'; -export const FILTER_ENTRIES_SUCCESS = 'FILTER_ENTRIES_SUCCESS'; -export const FILTER_ENTRIES_FAILURE = 'FILTER_ENTRIES_FAILURE'; - -export const GROUP_ENTRIES_REQUEST = 'GROUP_ENTRIES_REQUEST'; -export const GROUP_ENTRIES_SUCCESS = 'GROUP_ENTRIES_SUCCESS'; -export const GROUP_ENTRIES_FAILURE = 'GROUP_ENTRIES_FAILURE'; - -export const DRAFT_CREATE_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY'; -export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY'; -export const DRAFT_DISCARD = 'DRAFT_DISCARD'; -export const DRAFT_CHANGE_FIELD = 'DRAFT_CHANGE_FIELD'; -export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS'; -export const DRAFT_LOCAL_BACKUP_RETRIEVED = 'DRAFT_LOCAL_BACKUP_RETRIEVED'; -export const DRAFT_LOCAL_BACKUP_DELETE = 'DRAFT_LOCAL_BACKUP_DELETE'; -export const DRAFT_CREATE_FROM_LOCAL_BACKUP = 'DRAFT_CREATE_FROM_LOCAL_BACKUP'; -export const DRAFT_CREATE_DUPLICATE_FROM_ENTRY = 'DRAFT_CREATE_DUPLICATE_FROM_ENTRY'; - -export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST'; -export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS'; -export const ENTRY_PERSIST_FAILURE = 'ENTRY_PERSIST_FAILURE'; - -export const ENTRY_DELETE_REQUEST = 'ENTRY_DELETE_REQUEST'; -export const ENTRY_DELETE_SUCCESS = 'ENTRY_DELETE_SUCCESS'; -export const ENTRY_DELETE_FAILURE = 'ENTRY_DELETE_FAILURE'; - -export const ADD_DRAFT_ENTRY_MEDIA_FILE = 'ADD_DRAFT_ENTRY_MEDIA_FILE'; -export const REMOVE_DRAFT_ENTRY_MEDIA_FILE = 'REMOVE_DRAFT_ENTRY_MEDIA_FILE'; - -export const CHANGE_VIEW_STYLE = 'CHANGE_VIEW_STYLE'; - /* * Simple Action Creators (Internal) * We still need to export them for tests @@ -287,7 +279,7 @@ export function sortByField( return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); // if we're already fetching we update the sort key, but skip loading entries - const isFetching = selectIsFetching(state.entries, collection.name); + const isFetching = selectIsFetching(state, collection.name); dispatch(sortEntriesRequest(collection, key, direction)); if (isFetching) { return; @@ -307,7 +299,7 @@ export function filterByField(collection: Collection, filter: ViewFilter) { return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); // if we're already fetching we update the filter key, but skip loading entries - const isFetching = selectIsFetching(state.entries, collection.name); + const isFetching = selectIsFetching(state, collection.name); dispatch(filterEntriesRequest(collection, filter)); if (isFetching) { return; @@ -325,7 +317,7 @@ export function filterByField(collection: Collection, filter: ViewFilter) { export function groupByField(collection: Collection, group: ViewGroup) { return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const isFetching = selectIsFetching(state.entries, collection.name); + const isFetching = selectIsFetching(state, collection.name); dispatch({ type: GROUP_ENTRIES_REQUEST, payload: { @@ -657,7 +649,7 @@ export function loadEntries(collection: Collection, page = 0) { return; } const state = getState(); - const sortFields = selectEntriesSortFields(state.entries, collection.name); + const sortFields = selectEntriesSortFields(state, collection.name); if (sortFields && sortFields.length > 0) { const field = sortFields[0]; return dispatch(sortByField(collection, field.key, field.direction)); @@ -687,12 +679,6 @@ export function loadEntries(collection: Collection, page = 0) { const cleanResponse = { ...response, - // The only existing backend using the pagination system is the - // Algolia integration, which is also the only integration used - // to list entries. Thus, this checking for an integration can - // determine whether or not this is using the old integer-based - // pagination API. Other backends will simply store an empty - // cursor, which behaves identically to no cursor at all. cursor: !('cursor' in response && response.cursor) ? Cursor.create({ actions: ['next'], @@ -759,7 +745,7 @@ export function traverseCollectionCursor(collection: Collection, action: string) const { action: realAction, append } = action in appendActions ? appendActions[action] : { action, append: false }; - const cursor = selectCollectionEntriesCursor(state.cursors, collection.name); + const cursor = selectCollectionEntriesCursor(state, collection.name); // Handle cursors representing pages in the old, integer-based pagination API if (cursor.meta?.usingOldPaginationAPI ?? false) { diff --git a/packages/core/src/actions/media.ts b/packages/core/src/actions/media.ts index 215dcba0..4fc9be0f 100644 --- a/packages/core/src/actions/media.ts +++ b/packages/core/src/actions/media.ts @@ -1,6 +1,14 @@ +import { + ADD_ASSET, + ADD_ASSETS, + LOAD_ASSET_FAILURE, + LOAD_ASSET_REQUEST, + LOAD_ASSET_SUCCESS, + REMOVE_ASSET, +} from '../constants'; import { isAbsolutePath } from '../lib/util'; import { selectMediaFilePath } from '../lib/util/media.util'; -import { selectMediaFileByPath } from '../reducers/mediaLibrary'; +import { selectMediaFileByPath } from '../reducers/selectors/mediaLibrary'; import { createAssetProxy } from '../valueObjects/AssetProxy'; import { getMediaDisplayURL, getMediaFile, waitForMediaLibraryToLoad } from './mediaLibrary'; @@ -10,14 +18,6 @@ import type { BaseField, Collection, Entry, Field, UnknownField } from '../inter import type { RootState } from '../store'; import type AssetProxy from '../valueObjects/AssetProxy'; -export const ADD_ASSETS = 'ADD_ASSETS'; -export const ADD_ASSET = 'ADD_ASSET'; -export const REMOVE_ASSET = 'REMOVE_ASSET'; - -export const LOAD_ASSET_REQUEST = 'LOAD_ASSET_REQUEST'; -export const LOAD_ASSET_SUCCESS = 'LOAD_ASSET_SUCCESS'; -export const LOAD_ASSET_FAILURE = 'LOAD_ASSET_FAILURE'; - export function addAssets(assets: AssetProxy[]) { return { type: ADD_ASSETS, payload: assets } as const; } diff --git a/packages/core/src/actions/mediaLibrary.ts b/packages/core/src/actions/mediaLibrary.ts index 18f4a84e..ff084792 100644 --- a/packages/core/src/actions/mediaLibrary.ts +++ b/packages/core/src/actions/mediaLibrary.ts @@ -1,10 +1,29 @@ import { currentBackend } from '../backend'; import confirm from '../components/UI/Confirm'; +import { + MEDIA_DELETE_FAILURE, + MEDIA_DELETE_REQUEST, + MEDIA_DELETE_SUCCESS, + MEDIA_DISPLAY_URL_FAILURE, + MEDIA_DISPLAY_URL_REQUEST, + MEDIA_DISPLAY_URL_SUCCESS, + MEDIA_INSERT, + MEDIA_LIBRARY_CLOSE, + MEDIA_LIBRARY_CREATE, + MEDIA_LIBRARY_OPEN, + MEDIA_LOAD_FAILURE, + MEDIA_LOAD_REQUEST, + MEDIA_LOAD_SUCCESS, + MEDIA_PERSIST_FAILURE, + MEDIA_PERSIST_REQUEST, + MEDIA_PERSIST_SUCCESS, + MEDIA_REMOVE_INSERTED, +} from '../constants'; import { sanitizeSlug } from '../lib/urlHelper'; import { basename, getBlobSHA } from '../lib/util'; import { selectMediaFilePath, selectMediaFilePublicPath } from '../lib/util/media.util'; -import { selectEditingDraft } from '../reducers/entries'; -import { selectMediaDisplayURL, selectMediaFiles } from '../reducers/mediaLibrary'; +import { selectEditingDraft } from '../reducers/selectors/entryDraft'; +import { selectMediaDisplayURL, selectMediaFiles } from '../reducers/selectors/mediaLibrary'; import { addSnackbar } from '../store/slices/snackbars'; import { createAssetProxy } from '../valueObjects/AssetProxy'; import { addDraftEntryMediaFile, removeDraftEntryMediaFile } from './entries'; @@ -25,24 +44,6 @@ import type { import type { RootState } from '../store'; import type AssetProxy from '../valueObjects/AssetProxy'; -export const MEDIA_LIBRARY_OPEN = 'MEDIA_LIBRARY_OPEN'; -export const MEDIA_LIBRARY_CLOSE = 'MEDIA_LIBRARY_CLOSE'; -export const MEDIA_LIBRARY_CREATE = 'MEDIA_LIBRARY_CREATE'; -export const MEDIA_INSERT = 'MEDIA_INSERT'; -export const MEDIA_REMOVE_INSERTED = 'MEDIA_REMOVE_INSERTED'; -export const MEDIA_LOAD_REQUEST = 'MEDIA_LOAD_REQUEST'; -export const MEDIA_LOAD_SUCCESS = 'MEDIA_LOAD_SUCCESS'; -export const MEDIA_LOAD_FAILURE = 'MEDIA_LOAD_FAILURE'; -export const MEDIA_PERSIST_REQUEST = 'MEDIA_PERSIST_REQUEST'; -export const MEDIA_PERSIST_SUCCESS = 'MEDIA_PERSIST_SUCCESS'; -export const MEDIA_PERSIST_FAILURE = 'MEDIA_PERSIST_FAILURE'; -export const MEDIA_DELETE_REQUEST = 'MEDIA_DELETE_REQUEST'; -export const MEDIA_DELETE_SUCCESS = 'MEDIA_DELETE_SUCCESS'; -export const MEDIA_DELETE_FAILURE = 'MEDIA_DELETE_FAILURE'; -export const MEDIA_DISPLAY_URL_REQUEST = 'MEDIA_DISPLAY_URL_REQUEST'; -export const MEDIA_DISPLAY_URL_SUCCESS = 'MEDIA_DISPLAY_URL_SUCCESS'; -export const MEDIA_DISPLAY_URL_FAILURE = 'MEDIA_DISPLAY_URL_FAILURE'; - export function createMediaLibrary(instance: MediaLibraryInstance) { const api = { show: instance.show || (() => undefined), @@ -219,7 +220,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}) { const fileName = sanitizeSlug(file.name.toLowerCase(), config.slug); const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName); - const editingDraft = selectEditingDraft(state.entryDraft); + const editingDraft = selectEditingDraft(state); /** * Check for existing files of the same name before persisting. If no asset @@ -308,7 +309,7 @@ export function deleteMedia(file: MediaFile) { dispatch(removeAsset(file.path)); dispatch(removeDraftEntryMediaFile({ id: file.id })); } else { - const editingDraft = selectEditingDraft(state.entryDraft); + const editingDraft = selectEditingDraft(state); dispatch(mediaDeleting()); dispatch(removeAsset(file.path)); diff --git a/packages/core/src/actions/scroll.ts b/packages/core/src/actions/scroll.ts index 90cfd9ec..5e432f86 100644 --- a/packages/core/src/actions/scroll.ts +++ b/packages/core/src/actions/scroll.ts @@ -1,12 +1,9 @@ +import { SCROLL_SYNC_ENABLED, SET_SCROLL, TOGGLE_SCROLL } from '../constants'; + import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; import type { RootState } from '../store'; -export const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled'; - -export const TOGGLE_SCROLL = 'TOGGLE_SCROLL'; -export const SET_SCROLL = 'SET_SCROLL'; - export function togglingScroll() { return { type: TOGGLE_SCROLL, diff --git a/packages/core/src/actions/search.ts b/packages/core/src/actions/search.ts index 676bc1e9..e1aa88d5 100644 --- a/packages/core/src/actions/search.ts +++ b/packages/core/src/actions/search.ts @@ -1,25 +1,21 @@ import isEqual from 'lodash/isEqual'; import { currentBackend } from '../backend'; +import { + QUERY_FAILURE, + QUERY_REQUEST, + QUERY_SUCCESS, + SEARCH_CLEAR, + SEARCH_ENTRIES_FAILURE, + SEARCH_ENTRIES_REQUEST, + SEARCH_ENTRIES_SUCCESS, +} from '../constants'; import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; import type { Entry, SearchQueryResponse } from '../interface'; import type { RootState } from '../store'; -/* - * Constant Declarations - */ -export const SEARCH_ENTRIES_REQUEST = 'SEARCH_ENTRIES_REQUEST'; -export const SEARCH_ENTRIES_SUCCESS = 'SEARCH_ENTRIES_SUCCESS'; -export const SEARCH_ENTRIES_FAILURE = 'SEARCH_ENTRIES_FAILURE'; - -export const QUERY_REQUEST = 'QUERY_REQUEST'; -export const QUERY_SUCCESS = 'QUERY_SUCCESS'; -export const QUERY_FAILURE = 'QUERY_FAILURE'; - -export const SEARCH_CLEAR = 'SEARCH_CLEAR'; - /* * Simple Action Creators (Internal) * We still need to export them for tests diff --git a/packages/core/src/actions/status.ts b/packages/core/src/actions/status.ts index e27191e7..a03f298c 100644 --- a/packages/core/src/actions/status.ts +++ b/packages/core/src/actions/status.ts @@ -1,14 +1,11 @@ import { currentBackend } from '../backend'; +import { STATUS_FAILURE, STATUS_REQUEST, STATUS_SUCCESS } from '../constants'; import { addSnackbar, removeSnackbarById } from '../store/slices/snackbars'; import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; import type { RootState } from '../store'; -export const STATUS_REQUEST = 'STATUS_REQUEST'; -export const STATUS_SUCCESS = 'STATUS_SUCCESS'; -export const STATUS_FAILURE = 'STATUS_FAILURE'; - export function statusRequest() { return { type: STATUS_REQUEST, diff --git a/packages/core/src/actions/waitUntil.ts b/packages/core/src/actions/waitUntil.ts index df2382fd..759f5ed9 100644 --- a/packages/core/src/actions/waitUntil.ts +++ b/packages/core/src/actions/waitUntil.ts @@ -1,4 +1,4 @@ -import { WAIT_UNTIL_ACTION } from '../store/middleware/waitUntilAction'; +import { WAIT_UNTIL_ACTION } from '../constants'; import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; diff --git a/packages/core/src/bootstrap.tsx b/packages/core/src/bootstrap.tsx index 0d1194c0..559b83b8 100644 --- a/packages/core/src/bootstrap.tsx +++ b/packages/core/src/bootstrap.tsx @@ -15,7 +15,7 @@ import { ErrorBoundary } from './components/UI'; import addExtensions from './extensions'; import { getPhrases } from './lib/phrases'; import './mediaLibrary'; -import { selectLocale } from './reducers/config'; +import { selectLocale } from './reducers/selectors/config'; import { store } from './store'; import type { AnyAction } from '@reduxjs/toolkit'; diff --git a/packages/core/src/components/Collection/Collection.tsx b/packages/core/src/components/Collection/Collection.tsx index 5588a51d..2486dec0 100644 --- a/packages/core/src/components/Collection/Collection.tsx +++ b/packages/core/src/components/Collection/Collection.tsx @@ -22,15 +22,13 @@ import { selectEntriesGroup, selectEntriesSort, selectViewStyle, -} from '@staticcms/core/reducers/entries'; +} from '@staticcms/core/reducers/selectors/entries'; import CollectionControls from './CollectionControls'; import CollectionTop from './CollectionTop'; import EntriesCollection from './Entries/EntriesCollection'; import EntriesSearch from './Entries/EntriesSearch'; import Sidebar from './Sidebar'; -import type { ComponentType } from 'react'; -import type { ConnectedProps } from 'react-redux'; import type { Collection, SortDirection, @@ -39,6 +37,8 @@ import type { ViewGroup, } from '@staticcms/core/interface'; import type { RootState } from '@staticcms/core/store'; +import type { ComponentType } from 'react'; +import type { ConnectedProps } from 'react-redux'; const CollectionMain = styled('main')` width: 100%; @@ -271,13 +271,13 @@ function mapStateToProps(state: RootState, ownProps: TranslatedProps) => { const dispatch = useAppDispatch(); @@ -170,8 +171,9 @@ const EditorControl = ({ const fieldHint = field.hint; const path = useMemo( - () => (parentPath.length > 0 ? `${parentPath}.${field.name}` : field.name), - [field.name, parentPath], + () => + parentPath.length > 0 ? `${parentPath}.${fieldName ?? field.name}` : fieldName ?? field.name, + [field.name, fieldName, parentPath], ); const [dirty, setDirty] = useState(!isEmpty(value)); @@ -336,13 +338,14 @@ interface EditorControlOwnProps { value: ValueOrNestedValue; forList?: boolean; i18n: I18nSettings | undefined; + fieldName?: string; } function mapStateToProps(state: RootState, ownProps: EditorControlOwnProps) { const { collections, entryDraft } = state; const entry = entryDraft.entry; const collection = entryDraft.entry ? collections[entryDraft.entry.collection] : null; - const isLoadingAsset = selectIsLoadingAsset(state.medias); + const isLoadingAsset = selectIsLoadingAsset(state); return { ...ownProps, diff --git a/packages/core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx b/packages/core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx index aca7b0ce..84685bbf 100644 --- a/packages/core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx +++ b/packages/core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx @@ -12,7 +12,7 @@ import { lengths } from '@staticcms/core/components/UI/styles'; import { getPreviewStyles, getPreviewTemplate, resolveWidget } from '@staticcms/core/lib/registry'; import { selectTemplateName, useInferedFields } from '@staticcms/core/lib/util/collection.util'; import { selectField } from '@staticcms/core/lib/util/field.util'; -import { selectIsLoadingAsset } from '@staticcms/core/reducers/medias'; +import { selectIsLoadingAsset } from '@staticcms/core/reducers/selectors/medias'; import { getTypedFieldForValue } from '@staticcms/list/typedListHelpers'; import EditorPreview from './EditorPreview'; import EditorPreviewContent from './EditorPreviewContent'; @@ -106,17 +106,12 @@ function getWidgetFor( } const value = values?.[field.name]; - let fieldWithWidgets = Object.entries(field).reduce((acc, [key, fieldValue]) => { - if (!['fields', 'fields'].includes(key)) { - acc[key] = fieldValue; - } - return acc; - }, {} as Record) as RenderedField; + let fieldWithWidgets = field as RenderedField; if ('fields' in field && field.fields) { fieldWithWidgets = { ...fieldWithWidgets, - fields: getNestedWidgets( + renderedFields: getNestedWidgets( config, collection, fields, @@ -130,7 +125,7 @@ function getWidgetFor( } else if ('types' in field && field.types) { fieldWithWidgets = { ...fieldWithWidgets, - fields: getTypedNestedWidgets( + renderedFields: getTypedNestedWidgets( config, collection, field, @@ -594,7 +589,7 @@ export interface EditorPreviewPaneOwnProps { } function mapStateToProps(state: RootState, ownProps: EditorPreviewPaneOwnProps) { - const isLoadingAsset = selectIsLoadingAsset(state.medias); + const isLoadingAsset = selectIsLoadingAsset(state); return { ...ownProps, isLoadingAsset, config: state.config }; } diff --git a/packages/core/src/components/MediaLibrary/MediaLibrary.tsx b/packages/core/src/components/MediaLibrary/MediaLibrary.tsx index 5c4ed0d8..19bafe0f 100644 --- a/packages/core/src/components/MediaLibrary/MediaLibrary.tsx +++ b/packages/core/src/components/MediaLibrary/MediaLibrary.tsx @@ -12,15 +12,15 @@ import { persistMedia as persistMediaAction, } from '@staticcms/core/actions/mediaLibrary'; import { fileExtension } from '@staticcms/core/lib/util'; -import { selectMediaFiles } from '@staticcms/core/reducers/mediaLibrary'; +import { selectMediaFiles } from '@staticcms/core/reducers/selectors/mediaLibrary'; import alert from '../UI/Alert'; import confirm from '../UI/Confirm'; import MediaLibraryModal from './MediaLibraryModal'; -import type { ChangeEvent, KeyboardEvent } from 'react'; -import type { ConnectedProps } from 'react-redux'; import type { MediaFile, TranslatedProps } from '@staticcms/core/interface'; import type { RootState } from '@staticcms/core/store'; +import type { ChangeEvent, KeyboardEvent } from 'react'; +import type { ConnectedProps } from 'react-redux'; /** * Extensions used to determine which files to show when the media library is diff --git a/packages/core/src/components/UI/ListItemTopBar.tsx b/packages/core/src/components/UI/ListItemTopBar.tsx index bd1be7dc..eace0980 100644 --- a/packages/core/src/components/UI/ListItemTopBar.tsx +++ b/packages/core/src/components/UI/ListItemTopBar.tsx @@ -117,12 +117,12 @@ const ListItemTopBar = ({ /> ) : null} - + {title} {listeners ? : null} {onRemove ? ( - + ) : null} diff --git a/packages/core/src/components/UI/ObjectWidgetTopBar.tsx b/packages/core/src/components/UI/ObjectWidgetTopBar.tsx index f37a2655..4fc10809 100644 --- a/packages/core/src/components/UI/ObjectWidgetTopBar.tsx +++ b/packages/core/src/components/UI/ObjectWidgetTopBar.tsx @@ -53,6 +53,7 @@ export interface ObjectWidgetTopBarProps { heading: ReactNode; label?: string; hasError?: boolean; + testId?: string; } const ObjectWidgetTopBar = ({ @@ -66,6 +67,7 @@ const ObjectWidgetTopBar = ({ label, hasError = false, t, + testId, }: TranslatedProps) => { const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); @@ -129,6 +131,7 @@ const ObjectWidgetTopBar = ({ endIcon={} size="small" variant="outlined" + data-testid="add-button" > {t('editor.editorWidgets.list.add', { item: label })} @@ -147,7 +150,7 @@ const ObjectWidgetTopBar = ({ }, [allowAdd, types, renderTypesDropdown, renderAddButton]); return ( - + = Omit & { - fields?: ReactNode[]; +export type RenderedField = F & { + renderedFields?: ReactNode[]; }; export interface BaseField { @@ -576,7 +575,7 @@ export interface ObjectField extends BaseFi export interface ListField extends BaseField { widget: 'list'; - default?: ObjectValue[]; + default?: ValueOrNestedValue[]; allow_add?: boolean; collapsed?: boolean; @@ -756,7 +755,6 @@ export interface Config { media_folder_relative?: boolean; media_library?: MediaLibrary; load_config_file?: boolean; - integrations?: Integration[]; slug?: Slug; i18n?: I18nInfo; local_backend?: boolean | LocalBackend; @@ -815,23 +813,6 @@ export interface AuthenticationPageProps { clearHash?: () => void; } -export type Integration = { - collections?: '*' | string[]; -} & AlgoliaIntegration; - -export type SearchIntegrationProvider = 'algolia'; - -export interface AlgoliaIntegration extends AlgoliaConfig { - provider: 'algolia'; -} - -export interface AlgoliaConfig { - hooks: ['search' | 'listEntries']; - applicationID: string; - apiKey: string; - indexPrefix?: string; -} - export interface SearchResponse { entries: Entry[]; pagination: number; diff --git a/packages/core/src/lib/hooks/useMediaInsert.ts b/packages/core/src/lib/hooks/useMediaInsert.ts index 58e1affe..39c9a7f2 100644 --- a/packages/core/src/lib/hooks/useMediaInsert.ts +++ b/packages/core/src/lib/hooks/useMediaInsert.ts @@ -1,12 +1,12 @@ import { useCallback, useEffect, useMemo } from 'react'; import { v4 as uuid } from 'uuid'; -import { selectMediaPath } from '@staticcms/core/reducers/mediaLibrary'; -import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; import { openMediaLibrary, removeInsertedMedia } from '@staticcms/core/actions/mediaLibrary'; +import { selectMediaPath } from '@staticcms/core/reducers/selectors/mediaLibrary'; +import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; -import type { MouseEvent } from 'react'; import type { FileOrImageField, MarkdownField } from '@staticcms/core/interface'; +import type { MouseEvent } from 'react'; export default function useMediaInsert( value: T, diff --git a/packages/core/src/lib/test-utils/ControlWrapper.tsx b/packages/core/src/lib/test-utils/ControlWrapper.tsx new file mode 100644 index 00000000..06da9400 --- /dev/null +++ b/packages/core/src/lib/test-utils/ControlWrapper.tsx @@ -0,0 +1,101 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import React from 'react'; + +import type { + BaseField, + Collection, + Config, + Entry, + UnknownField, + WidgetControlProps, +} from '@staticcms/core/interface'; +import type { FC } from 'react'; +import type { t } from 'react-polyglot'; + +export interface CreateControlWrapper { + defaultField: F; + control: FC>; + label: string; + path: string; +} + +const createControlWrapper = ({ + defaultField, + control: Control, + label: defaultLabel, + path: defaultPath, +}: CreateControlWrapper) => { + const ControlWrapper: FC>> = ({ + collection = {} as Collection, + config = {} as Config, + entry = {} as Entry, + field = defaultField, + fieldsErrors = {}, + submitted = false, + forList = false, + getAsset = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Promise.resolve(null) as any; + }, + isDisabled = false, + isFieldDuplicate = () => false, + isFieldHidden = () => false, + label = defaultLabel, + locale = 'en', + mediaPaths = {}, + onChange = () => {}, + clearMediaControl = () => {}, + openMediaLibrary = () => {}, + removeInsertedMedia = () => ({ + type: 'MEDIA_REMOVE_INSERTED', + payload: { + controlID: '123456', + }, + }), + removeMediaControl = () => {}, + i18n = undefined, + hasErrors = false, + path = defaultPath, + query = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Promise.resolve('') as any; + }, + t = (() => '') as t, + value = undefined, + }) => { + return ( + + ); + }; + + return ControlWrapper; +}; + +export default createControlWrapper; diff --git a/packages/core/src/lib/test-utils/mock-data/MockEntry.ts b/packages/core/src/lib/test-utils/mock-data/MockEntry.ts new file mode 100644 index 00000000..a80350c3 --- /dev/null +++ b/packages/core/src/lib/test-utils/mock-data/MockEntry.ts @@ -0,0 +1,17 @@ +import type { Entry } from '@staticcms/core/interface'; + +const mockEntry: Entry = { + collection: 'collection', + slug: 'slug', + path: '', + partial: false, + raw: '', + data: {}, + label: 'Entry', + isModification: false, + mediaFiles: [], + author: '', + updatedOn: '', +}; + +export default mockEntry; diff --git a/packages/core/src/reducers/__tests__/entryDraft.spec.ts b/packages/core/src/reducers/__tests__/entryDraft.spec.ts new file mode 100644 index 00000000..1d524535 --- /dev/null +++ b/packages/core/src/reducers/__tests__/entryDraft.spec.ts @@ -0,0 +1,77 @@ +import { DRAFT_CHANGE_FIELD, DRAFT_CREATE_EMPTY } from '@staticcms/core/constants'; +import mockEntry from '@staticcms/core/lib/test-utils/mock-data/MockEntry'; +import entryDraftReducer from '../entryDraft'; + +import type { EntryDraftState } from '../entryDraft'; + +describe('entryDraft', () => { + describe('reducer', () => { + describe('DRAFT_CHANGE_FIELD', () => { + let startState: EntryDraftState; + + beforeEach(() => { + startState = entryDraftReducer(undefined, { + type: DRAFT_CREATE_EMPTY, + payload: mockEntry, + }); + }); + + it('should update path with value', () => { + const state = entryDraftReducer(startState, { + type: DRAFT_CHANGE_FIELD, + payload: { + path: 'path1.path2', + field: { + widget: 'string', + name: 'stringInput', + }, + value: 'newValue', + i18n: undefined, + }, + }); + + expect(state.entry?.data).toEqual({ + path1: { + path2: 'newValue', + }, + }); + }); + + it('should update path with value for singleton list', () => { + let state = entryDraftReducer(startState, { + type: DRAFT_CHANGE_FIELD, + payload: { + path: 'path1', + field: { + widget: 'string', + name: 'stringInput', + }, + value: ['newValue1', 'newValue2', 'newValue3'], + i18n: undefined, + }, + }); + + expect(state.entry?.data).toEqual({ + path1: ['newValue1', 'newValue2', 'newValue3'], + }); + + state = entryDraftReducer(state, { + type: DRAFT_CHANGE_FIELD, + payload: { + path: 'path1.1', + field: { + widget: 'string', + name: 'stringInput', + }, + value: 'newValue2Updated', + i18n: undefined, + }, + }); + + expect(state.entry?.data).toEqual({ + path1: ['newValue1', 'newValue2Updated', 'newValue3'], + }); + }); + }); + }); +}); diff --git a/packages/core/src/reducers/auth.ts b/packages/core/src/reducers/auth.ts index 4379e80f..58be9eb0 100644 --- a/packages/core/src/reducers/auth.ts +++ b/packages/core/src/reducers/auth.ts @@ -1,15 +1,9 @@ import { produce } from 'immer'; -import { - AUTH_REQUEST, - AUTH_SUCCESS, - AUTH_FAILURE, - AUTH_REQUEST_DONE, - LOGOUT, -} from '../actions/auth'; +import { AUTH_FAILURE, AUTH_REQUEST, AUTH_REQUEST_DONE, AUTH_SUCCESS, LOGOUT } from '../constants'; -import type { User } from '../interface'; import type { AuthAction } from '../actions/auth'; +import type { User } from '../interface'; export type AuthState = { isFetching: boolean; diff --git a/packages/core/src/reducers/collections.ts b/packages/core/src/reducers/collections.ts index 67996f7f..caf57eef 100644 --- a/packages/core/src/reducers/collections.ts +++ b/packages/core/src/reducers/collections.ts @@ -1,8 +1,7 @@ -import { CONFIG_SUCCESS } from '../actions/config'; +import { CONFIG_SUCCESS } from '../constants'; import type { ConfigAction } from '../actions/config'; import type { Collection, Collections } from '../interface'; -import type { RootState } from '../store'; export type CollectionsState = Collections; @@ -26,7 +25,3 @@ function collections( } export default collections; - -export const selectCollection = (collectionName: string) => (state: RootState) => { - return Object.values(state.collections).find(collection => collection.name === collectionName); -}; diff --git a/packages/core/src/reducers/config.ts b/packages/core/src/reducers/config.ts index 07d5e025..6c38be68 100644 --- a/packages/core/src/reducers/config.ts +++ b/packages/core/src/reducers/config.ts @@ -1,6 +1,4 @@ -import { produce } from 'immer'; - -import { CONFIG_FAILURE, CONFIG_REQUEST, CONFIG_SUCCESS } from '../actions/config'; +import { CONFIG_FAILURE, CONFIG_REQUEST, CONFIG_SUCCESS } from '../constants'; import type { ConfigAction } from '../actions/config'; import type { Config } from '../interface'; @@ -15,11 +13,13 @@ const defaultState: ConfigState = { isFetching: true, }; -const config = produce((state: ConfigState, action: ConfigAction) => { +const config = (state: ConfigState = defaultState, action: ConfigAction) => { switch (action.type) { case CONFIG_REQUEST: - state.isFetching = true; - break; + return { + ...state, + isFetching: true, + }; case CONFIG_SUCCESS: return { config: action.payload, @@ -27,13 +27,15 @@ const config = produce((state: ConfigState, action: ConfigAction) => { error: undefined, }; case CONFIG_FAILURE: - state.isFetching = false; - state.error = action.payload.toString(); - } -}, defaultState); + return { + ...state, + isFetching: false, + error: action.payload.toString(), + }; -export function selectLocale(state?: Config) { - return state?.locale || 'en'; -} + default: + return state; + } +}; export default config; diff --git a/packages/core/src/reducers/cursors.ts b/packages/core/src/reducers/cursors.ts index 14d14c5c..dc82d7a2 100644 --- a/packages/core/src/reducers/cursors.ts +++ b/packages/core/src/reducers/cursors.ts @@ -3,7 +3,7 @@ import { FILTER_ENTRIES_SUCCESS, GROUP_ENTRIES_SUCCESS, SORT_ENTRIES_SUCCESS, -} from '../actions/entries'; +} from '../constants'; import { Cursor } from '../lib/util'; import type { EntriesAction } from '../actions/entries'; @@ -50,11 +50,4 @@ function cursors( } } -// Since pagination can be used for a variety of views (collections -// and searches are the most common examples), we namespace cursors by -// their type before storing them in the state. -export function selectCollectionEntriesCursor(state: CursorsState, collectionName: string) { - return new Cursor(state.cursorsByType.collectionEntries[collectionName]); -} - export default cursors; diff --git a/packages/core/src/reducers/entries.ts b/packages/core/src/reducers/entries.ts index b08a220a..15c7dac5 100644 --- a/packages/core/src/reducers/entries.ts +++ b/packages/core/src/reducers/entries.ts @@ -1,7 +1,4 @@ -import get from 'lodash/get'; -import groupBy from 'lodash/groupBy'; import once from 'lodash/once'; -import orderBy from 'lodash/orderBy'; import sortBy from 'lodash/sortBy'; import { @@ -19,34 +16,29 @@ import { GROUP_ENTRIES_FAILURE, GROUP_ENTRIES_REQUEST, GROUP_ENTRIES_SUCCESS, + SEARCH_ENTRIES_SUCCESS, SORT_ENTRIES_FAILURE, SORT_ENTRIES_REQUEST, SORT_ENTRIES_SUCCESS, -} from '../actions/entries'; -import { SEARCH_ENTRIES_SUCCESS } from '../actions/search'; -import { SORT_DIRECTION_ASCENDING, SORT_DIRECTION_NONE } from '../constants'; +} from '../constants'; import { VIEW_STYLE_LIST } from '../constants/collectionViews'; import { set } from '../lib/util/object.util'; -import { selectSortDataPath } from '../lib/util/sort.util'; import type { EntriesAction } from '../actions/entries'; import type { SearchAction } from '../actions/search'; import type { CollectionViewStyle } from '../constants/collectionViews'; import type { - Collection, Entities, Entry, Filter, FilterMap, Group, GroupMap, - GroupOfEntries, Pages, Sort, SortMap, SortObject, } from '../interface'; -import type { EntryDraftState } from './entryDraft'; const storageSortKey = '../netlify-cms.entries.sort'; const viewStyleKey = '../netlify-cms.entries.viewStyle'; @@ -545,173 +537,4 @@ function entries( } } -export function selectEntriesSort(entries: EntriesState, collection: string) { - const sort = entries.sort as Sort | undefined; - return sort?.[collection]; -} - -export function selectEntriesFilter(entries: EntriesState, collection: string) { - const filter = entries.filter as Filter | undefined; - return filter?.[collection] || {}; -} - -export function selectEntriesGroup(entries: EntriesState, collection: string) { - const group = entries.group as Group | undefined; - return group?.[collection] || {}; -} - -export function selectEntriesGroupField(entries: EntriesState, collection: string) { - const groups = selectEntriesGroup(entries, collection); - const value = Object.values(groups ?? {}).find(v => v?.active === true); - return value; -} - -export function selectEntriesSortFields(entries: EntriesState, 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: EntriesState, collection: string) { - const filter = selectEntriesFilter(entries, collection); - const values = Object.values(filter ?? {}).filter(v => v?.active === true) || []; - return values; -} - -export function selectViewStyle(entries: EntriesState): CollectionViewStyle { - return entries.viewStyle; -} - -export function selectEntry(state: EntriesState, collection: string, slug: string) { - return state.entities[`${collection}.${slug}`]; -} - -export function selectPublishedSlugs(state: EntriesState, collection: string) { - return state.pages[collection]?.ids ?? []; -} - -function getPublishedEntries(state: EntriesState, collectionName: string) { - const slugs = selectPublishedSlugs(state, collectionName); - const entries = - slugs && (slugs.map(slug => selectEntry(state, collectionName, slug as string)) as Entry[]); - return entries; -} - -export function selectEntries(state: EntriesState, 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) { - const label = selectedGroup.label; - const field = selectedGroup.field; - - const fieldData = get(entry.data, field); - if (fieldData === undefined) { - return { - id: 'missing_value', - label, - value: fieldData, - }; - } - - const dataAsString = String(fieldData); - if (selectedGroup.pattern) { - const pattern = selectedGroup.pattern; - let value = ''; - try { - const regex = new RegExp(pattern); - const matched = dataAsString.match(regex); - if (matched) { - value = matched[0]; - } - } catch (e: unknown) { - console.warn(`Invalid view group pattern '${pattern}' for field '${field}'`, e); - } - return { - id: `${label}${value}`, - label, - value, - }; - } - - return { - id: `${label}${fieldData}`, - label, - value: typeof fieldData === 'boolean' ? fieldData : dataAsString, - }; -} - -export function selectGroups(state: EntriesState, collection: Collection) { - const collectionName = collection.name; - const entries = getPublishedEntries(state, collectionName); - - const selectedGroup = selectEntriesGroupField(state, collectionName); - if (selectedGroup === undefined) { - return []; - } - - let groups: Record = - {}; - 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: EntriesState, collection: string, path: string) { - const slugs = selectPublishedSlugs(state, collection); - const entries = - slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as Entry[]); - - return entries && entries.find(e => e?.path === path); -} - -export function selectEntriesLoaded(state: EntriesState, collection: string) { - return !!state.pages[collection]; -} - -export function selectIsFetching(state: EntriesState, collection: string) { - return state.pages[collection]?.isFetching ?? false; -} - -export function selectEditingDraft(state: EntryDraftState) { - return state.entry; -} - export default entries; diff --git a/packages/core/src/reducers/entryDraft.ts b/packages/core/src/reducers/entryDraft.ts index 4d8bd2dd..243ba856 100644 --- a/packages/core/src/reducers/entryDraft.ts +++ b/packages/core/src/reducers/entryDraft.ts @@ -18,13 +18,12 @@ import { ENTRY_PERSIST_REQUEST, ENTRY_PERSIST_SUCCESS, REMOVE_DRAFT_ENTRY_MEDIA_FILE, -} from '../actions/entries'; +} from '../constants'; import { duplicateI18nFields, getDataPath } from '../lib/i18n'; import { set } from '../lib/util/object.util'; import type { EntriesAction } from '../actions/entries'; import type { Entry, FieldsErrors } from '../interface'; -import type { RootState } from '../store'; export interface EntryDraftState { original?: Entry; @@ -301,7 +300,3 @@ function entryDraftReducer( } export default entryDraftReducer; - -export const selectFieldErrors = (path: string) => (state: RootState) => { - return state.entryDraft.fieldsErrors[path] ?? []; -}; diff --git a/packages/core/src/reducers/index.ts b/packages/core/src/reducers/index.ts index 0ee6a90d..f137b573 100644 --- a/packages/core/src/reducers/index.ts +++ b/packages/core/src/reducers/index.ts @@ -2,7 +2,7 @@ import auth from './auth'; import collections from './collections'; import config from './config'; import cursors from './cursors'; -import entries, * as fromEntries from './entries'; +import entries from './entries'; import entryDraft from './entryDraft'; import globalUI from './globalUI'; import mediaLibrary from './mediaLibrary'; @@ -11,9 +11,6 @@ import scroll from './scroll'; import search from './search'; import status from './status'; -import type { Collection } from '../interface'; -import type { RootState } from '../store'; - const reducers = { auth, collections, @@ -30,25 +27,3 @@ const reducers = { }; export default reducers; - -/* - * Selectors - */ -export function selectEntry(state: RootState, collection: string, slug: string) { - return fromEntries.selectEntry(state.entries, collection, slug); -} - -export function selectEntries(state: RootState, collection: Collection) { - return fromEntries.selectEntries(state.entries, collection); -} - -export function selectPublishedSlugs(state: RootState, collection: string) { - return fromEntries.selectPublishedSlugs(state.entries, collection); -} - -export function selectSearchedEntries(state: RootState, availableCollections: string[]) { - // only return search results for actually available collections - return state.search.entryIds - .filter(entryId => availableCollections.indexOf(entryId!.collection) !== -1) - .map(entryId => fromEntries.selectEntry(state.entries, entryId!.collection, entryId!.slug)); -} diff --git a/packages/core/src/reducers/mediaLibrary.ts b/packages/core/src/reducers/mediaLibrary.ts index a612fcf6..7a6890da 100644 --- a/packages/core/src/reducers/mediaLibrary.ts +++ b/packages/core/src/reducers/mediaLibrary.ts @@ -1,5 +1,3 @@ -import get from 'lodash/get'; -import { dirname } from 'path'; import { v4 as uuid } from 'uuid'; import { @@ -20,13 +18,10 @@ import { MEDIA_PERSIST_REQUEST, MEDIA_PERSIST_SUCCESS, MEDIA_REMOVE_INSERTED, -} from '../actions/mediaLibrary'; -import { selectMediaFolder } from '../lib/util/media.util'; -import { selectEditingDraft } from './entries'; +} from '../constants'; import type { MediaLibraryAction } from '../actions/mediaLibrary'; -import type { DisplayURLState, Field, MediaFile, MediaLibraryInstance } from '../interface'; -import type { RootState } from '../store'; +import type { Field, MediaFile, MediaLibraryInstance } from '../interface'; export interface MediaLibraryDisplayURL { url?: string; @@ -287,41 +282,4 @@ function mediaLibrary( } } -export function selectMediaFiles(state: RootState, field?: Field): MediaFile[] { - const { mediaLibrary, entryDraft } = state; - const editingDraft = selectEditingDraft(entryDraft); - - let files: MediaFile[] = []; - if (editingDraft) { - const entryFiles = entryDraft?.entry?.mediaFiles ?? []; - const entry = entryDraft['entry']; - const collection = entry?.collection ? state.collections[entry.collection] : null; - if (state.config.config) { - const mediaFolder = selectMediaFolder(state.config.config, collection, entry, field); - files = entryFiles - .filter(f => dirname(f.path) === mediaFolder) - .map(file => ({ key: file.id, ...file })); - } - } else { - files = mediaLibrary.files || []; - } - - return files; -} - -export function selectMediaFileByPath(state: RootState, path: string) { - const files = selectMediaFiles(state); - const file = files.find(file => file.path === path); - return file; -} - -export function selectMediaDisplayURL(state: RootState, id: string) { - const displayUrlState = (get(state.mediaLibrary, ['displayURLs', id]) ?? {}) as DisplayURLState; - return displayUrlState; -} - -export const selectMediaPath = (controlID: string) => (state: RootState) => { - return state.mediaLibrary.controlMedia[controlID]; -}; - export default mediaLibrary; diff --git a/packages/core/src/reducers/medias.ts b/packages/core/src/reducers/medias.ts index 51f5ab20..dd479612 100644 --- a/packages/core/src/reducers/medias.ts +++ b/packages/core/src/reducers/medias.ts @@ -1,66 +1,74 @@ -import { produce } from 'immer'; - import { - ADD_ASSETS, ADD_ASSET, - REMOVE_ASSET, + ADD_ASSETS, + LOAD_ASSET_FAILURE, LOAD_ASSET_REQUEST, LOAD_ASSET_SUCCESS, - LOAD_ASSET_FAILURE, -} from '../actions/media'; + REMOVE_ASSET, +} from '../constants'; import type { MediasAction } from '../actions/media'; import type AssetProxy from '../valueObjects/AssetProxy'; -export interface MediasState { - [path: string]: { asset: AssetProxy | undefined; isLoading: boolean; error: Error | null }; -} +export type MediasState = Record< + string, + { asset: AssetProxy | undefined; isLoading: boolean; error: Error | null } +>; const defaultState: MediasState = {}; -const medias = produce((state: MediasState, action: MediasAction) => { +const medias = (state: MediasState = defaultState, action: MediasAction) => { switch (action.type) { case ADD_ASSETS: { const assets = action.payload; + const newState = { + ...state, + }; assets.forEach(asset => { - state[asset.path] = { asset, isLoading: false, error: null }; + newState[asset.path] = { asset, isLoading: false, error: null }; }); - break; + return newState; } case ADD_ASSET: { const asset = action.payload; - state[asset.path] = { asset, isLoading: false, error: null }; - break; + return { + ...state, + [asset.path]: { asset, isLoading: false, error: null }, + }; } case REMOVE_ASSET: { const path = action.payload; - delete state[path]; - break; + const newState = { + ...state, + }; + delete newState[path]; + return newState; } case LOAD_ASSET_REQUEST: { const { path } = action.payload; - state[path] = state[path] || {}; - state[path].isLoading = true; - break; + return { + ...state, + [path]: { ...state[path], isLoading: true }, + }; } case LOAD_ASSET_SUCCESS: { const { path } = action.payload; - state[path] = state[path] || {}; - state[path].isLoading = false; - state[path].error = null; - break; + return { + ...state, + [path]: { ...state[path], isLoading: false, error: null }, + }; } case LOAD_ASSET_FAILURE: { const { path, error } = action.payload; - state[path] = state[path] || {}; - state[path].isLoading = false; - state[path].error = error; + return { + ...state, + [path]: { ...state[path], isLoading: false, error }, + }; } - } -}, defaultState); -export function selectIsLoadingAsset(state: MediasState) { - return Object.values(state).some(state => state.isLoading); -} + default: + return state; + } +}; export default medias; diff --git a/packages/core/src/reducers/scroll.ts b/packages/core/src/reducers/scroll.ts index a7fea961..65ed390d 100644 --- a/packages/core/src/reducers/scroll.ts +++ b/packages/core/src/reducers/scroll.ts @@ -1,6 +1,6 @@ import { produce } from 'immer'; -import { SCROLL_SYNC_ENABLED, SET_SCROLL, TOGGLE_SCROLL } from '../actions/scroll'; +import { SCROLL_SYNC_ENABLED, SET_SCROLL, TOGGLE_SCROLL } from '../constants'; import type { ScrollAction } from '../actions/scroll'; diff --git a/packages/core/src/reducers/search.ts b/packages/core/src/reducers/search.ts index cb3a3119..e0e91332 100644 --- a/packages/core/src/reducers/search.ts +++ b/packages/core/src/reducers/search.ts @@ -6,7 +6,7 @@ import { SEARCH_ENTRIES_FAILURE, SEARCH_ENTRIES_REQUEST, SEARCH_ENTRIES_SUCCESS, -} from '../actions/search'; +} from '../constants'; import type { SearchAction } from '../actions/search'; diff --git a/packages/core/src/reducers/selectors/collections.ts b/packages/core/src/reducers/selectors/collections.ts new file mode 100644 index 00000000..9ba69dc6 --- /dev/null +++ b/packages/core/src/reducers/selectors/collections.ts @@ -0,0 +1,6 @@ +/* eslint-disable import/prefer-default-export */ +import type { RootState } from '@staticcms/core/store'; + +export const selectCollection = (collectionName: string) => (state: RootState) => { + return Object.values(state.collections).find(collection => collection.name === collectionName); +}; diff --git a/packages/core/src/reducers/selectors/config.ts b/packages/core/src/reducers/selectors/config.ts new file mode 100644 index 00000000..ac9519e1 --- /dev/null +++ b/packages/core/src/reducers/selectors/config.ts @@ -0,0 +1,7 @@ +/* eslint-disable import/prefer-default-export */ + +import type { Config } from '@staticcms/core/interface'; + +export function selectLocale(config?: Config) { + return config?.locale || 'en'; +} diff --git a/packages/core/src/reducers/selectors/cursors.ts b/packages/core/src/reducers/selectors/cursors.ts new file mode 100644 index 00000000..3d035da2 --- /dev/null +++ b/packages/core/src/reducers/selectors/cursors.ts @@ -0,0 +1,12 @@ +/* eslint-disable import/prefer-default-export */ + +import Cursor from '@staticcms/core/lib/util/Cursor'; + +import type { RootState } from '@staticcms/core/store'; + +// Since pagination can be used for a variety of views (collections +// and searches are the most common examples), we namespace cursors by +// their type before storing them in the state. +export function selectCollectionEntriesCursor(state: RootState, collectionName: string) { + return new Cursor(state.cursors.cursorsByType.collectionEntries[collectionName]); +} diff --git a/packages/core/src/reducers/selectors/entries.ts b/packages/core/src/reducers/selectors/entries.ts new file mode 100644 index 00000000..e48723d6 --- /dev/null +++ b/packages/core/src/reducers/selectors/entries.ts @@ -0,0 +1,190 @@ +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 type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews'; +import type { + Collection, + Entry, + Filter, + Group, + GroupMap, + GroupOfEntries, + Sort, +} from '@staticcms/core/interface'; +import type { RootState } from '@staticcms/core/store'; + +export function selectEntriesSort(entries: RootState, collection: string) { + const sort = entries.entries.sort as Sort | undefined; + return sort?.[collection]; +} + +export function selectEntriesFilter(entries: RootState, collection: string) { + const filter = entries.entries.filter as Filter | undefined; + return filter?.[collection] || {}; +} + +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) { + const groups = selectEntriesGroup(entries, collection); + const value = Object.values(groups ?? {}).find(v => v?.active === true); + return value; +} + +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 function selectViewStyle(entries: RootState): CollectionViewStyle { + return entries.entries.viewStyle; +} + +export function selectEntry(state: RootState, collection: string, slug: string) { + return state.entries.entities[`${collection}.${slug}`]; +} + +export function selectPublishedSlugs(state: RootState, collection: string) { + 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 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) { + const label = selectedGroup.label; + const field = selectedGroup.field; + + const fieldData = get(entry.data, field); + if (fieldData === undefined) { + return { + id: 'missing_value', + label, + value: fieldData, + }; + } + + const dataAsString = String(fieldData); + if (selectedGroup.pattern) { + const pattern = selectedGroup.pattern; + let value = ''; + try { + const regex = new RegExp(pattern); + const matched = dataAsString.match(regex); + if (matched) { + value = matched[0]; + } + } catch (e: unknown) { + console.warn(`Invalid view group pattern '${pattern}' for field '${field}'`, e); + } + return { + id: `${label}${value}`, + label, + value, + }; + } + + return { + id: `${label}${fieldData}`, + label, + value: typeof fieldData === 'boolean' ? fieldData : dataAsString, + }; +} + +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 = + {}; + 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 entries = + slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as Entry[]); + + return entries && entries.find(e => e?.path === path); +} + +export function selectEntriesLoaded(state: RootState, collection: string) { + return !!state.entries.pages[collection]; +} + +export function selectIsFetching(state: RootState, collection: string) { + return state.entries.pages[collection]?.isFetching ?? false; +} + +export function selectSearchedEntries(state: RootState, availableCollections: string[]) { + // only return search results for actually available collections + return state.search.entryIds + .filter(entryId => availableCollections.indexOf(entryId!.collection) !== -1) + .map(entryId => selectEntry(state, entryId!.collection, entryId!.slug)); +} diff --git a/packages/core/src/reducers/selectors/entryDraft.ts b/packages/core/src/reducers/selectors/entryDraft.ts new file mode 100644 index 00000000..72da61c7 --- /dev/null +++ b/packages/core/src/reducers/selectors/entryDraft.ts @@ -0,0 +1,10 @@ +/* eslint-disable import/prefer-default-export */ +import type { RootState } from '@staticcms/core/store'; + +export const selectFieldErrors = (path: string) => (state: RootState) => { + return state.entryDraft.fieldsErrors[path] ?? []; +}; + +export function selectEditingDraft(state: RootState) { + return state.entryDraft.entry; +} diff --git a/packages/core/src/reducers/selectors/mediaLibrary.ts b/packages/core/src/reducers/selectors/mediaLibrary.ts new file mode 100644 index 00000000..f8dd86c0 --- /dev/null +++ b/packages/core/src/reducers/selectors/mediaLibrary.ts @@ -0,0 +1,44 @@ +import get from 'lodash/get'; +import { dirname } from 'path'; + +import { selectMediaFolder } from '@staticcms/core/lib/util/media.util'; +import { selectEditingDraft } from './entryDraft'; + +import type { DisplayURLState, Field, MediaFile } from '@staticcms/core/interface'; +import type { RootState } from '@staticcms/core/store'; + +export function selectMediaFiles(state: RootState, field?: Field): MediaFile[] { + const { mediaLibrary, entryDraft } = state; + const editingDraft = selectEditingDraft(state); + + let files: MediaFile[] = []; + if (editingDraft) { + const entryFiles = entryDraft?.entry?.mediaFiles ?? []; + const entry = entryDraft['entry']; + const collection = entry?.collection ? state.collections[entry.collection] : null; + if (state.config.config) { + const mediaFolder = selectMediaFolder(state.config.config, collection, entry, field); + files = entryFiles + .filter(f => dirname(f.path) === mediaFolder) + .map(file => ({ key: file.id, ...file })); + } + } else { + files = mediaLibrary.files || []; + } + + return files; +} + +export function selectMediaFileByPath(state: RootState, path: string) { + const files = selectMediaFiles(state); + const file = files.find(file => file.path === path); + return file; +} + +export function selectMediaDisplayURL(state: RootState, id: string) { + return (get(state.mediaLibrary, ['displayURLs', id]) ?? {}) as DisplayURLState; +} + +export const selectMediaPath = (controlID: string) => (state: RootState) => { + return state.mediaLibrary.controlMedia[controlID]; +}; diff --git a/packages/core/src/reducers/selectors/medias.ts b/packages/core/src/reducers/selectors/medias.ts new file mode 100644 index 00000000..e7bcc183 --- /dev/null +++ b/packages/core/src/reducers/selectors/medias.ts @@ -0,0 +1,7 @@ +/* eslint-disable import/prefer-default-export */ + +import type { RootState } from '@staticcms/core/store'; + +export function selectIsLoadingAsset(state: RootState) { + return Object.values(state.medias).some(state => state.isLoading); +} diff --git a/packages/core/src/reducers/status.ts b/packages/core/src/reducers/status.ts index 997e591a..c86793be 100644 --- a/packages/core/src/reducers/status.ts +++ b/packages/core/src/reducers/status.ts @@ -1,6 +1,6 @@ import { produce } from 'immer'; -import { STATUS_REQUEST, STATUS_SUCCESS, STATUS_FAILURE } from '../actions/status'; +import { STATUS_REQUEST, STATUS_SUCCESS, STATUS_FAILURE } from '../constants'; import type { StatusAction } from '../actions/status'; diff --git a/packages/core/src/store/middleware/waitUntilAction.ts b/packages/core/src/store/middleware/waitUntilAction.ts index 16d35cb4..13fd5abb 100644 --- a/packages/core/src/store/middleware/waitUntilAction.ts +++ b/packages/core/src/store/middleware/waitUntilAction.ts @@ -8,9 +8,9 @@ * action coming through the system. Think of it as a thunk that * blocks until the condition is met. */ -import type { AnyAction, Dispatch, Middleware, MiddlewareAPI } from '@reduxjs/toolkit'; +import { WAIT_UNTIL_ACTION } from '@staticcms/core/constants'; -export const WAIT_UNTIL_ACTION = 'WAIT_UNTIL_ACTION'; +import type { AnyAction, Dispatch, Middleware, MiddlewareAPI } from '@reduxjs/toolkit'; export interface WaitActionArgs { predicate: (action: AnyAction) => boolean; diff --git a/packages/core/src/widgets/list/ListControl.tsx b/packages/core/src/widgets/list/ListControl.tsx index d657f66e..09050eff 100644 --- a/packages/core/src/widgets/list/ListControl.tsx +++ b/packages/core/src/widgets/list/ListControl.tsx @@ -60,7 +60,7 @@ const StyledSortableList = styled( interface SortableItemProps { id: string; - item: ObjectValue; + item: ValueOrNestedValue; index: number; valueType: ListValueType; handleRemove: (index: number, event: MouseEvent) => void; @@ -106,7 +106,7 @@ const SortableItem: FC = ({ } return ( -
+
= ({ isFieldHidden={isFieldHidden} locale={locale} path={path} - value={item as Record} + value={item} i18n={i18n} listeners={listeners} /> @@ -135,7 +135,28 @@ export enum ListValueType { MIXED, } -function getFieldsDefault(fields: Field[], initialValue: ObjectValue = {}): ObjectValue { +function getFieldsDefault( + fields: Field[], + initialValue: ValueOrNestedValue = {}, +): ValueOrNestedValue { + if (fields.length === 1) { + if ('default' in fields[0] && fields[0].default) { + return fields[0].default; + } + + switch (fields[0].widget) { + case 'string': + case 'text': + return ''; + case 'boolean': + return false; + case 'number': + return 0; + } + + return null; + } + return fields.reduce((acc, item) => { const subfields = 'fields' in item && item.fields; const name = item.name; @@ -159,10 +180,10 @@ function getFieldsDefault(fields: Field[], initialValue: ObjectValue = {}): Obje } return acc; - }, initialValue); + }, initialValue as ObjectValue); } -const ListControl: FC> = ({ +const ListControl: FC> = ({ entry, field, fieldsErrors, @@ -196,7 +217,7 @@ const ListControl: FC> = ({ }, []); const mixedDefault = useCallback( - (typeKey: string, type: string): ObjectValue => { + (typeKey: string, type: string): ValueOrNestedValue => { const selectedType = 'types' in field && field.types?.find(f => f.name === type); if (!selectedType) { return {}; @@ -208,7 +229,7 @@ const ListControl: FC> = ({ ); const addItem = useCallback( - (parsedValue: ObjectValue) => { + (parsedValue: ValueOrNestedValue) => { const addToTop = field.add_to_top ?? false; const newKeys = [...keys]; @@ -221,7 +242,7 @@ const ListControl: FC> = ({ newValue.push(parsedValue); } setKeys(newKeys); - onChange(newValue); + onChange(newValue as string[] | ObjectValue[]); setCollapsed(false); }, [field.add_to_top, onChange, internalValue, keys], @@ -230,7 +251,8 @@ const ListControl: FC> = ({ const handleAdd = useCallback( (e: MouseEvent) => { e.preventDefault(); - addItem('fields' in field && field.fields ? multipleDefault(field.fields) : {}); + const parsedValue = multipleDefault(field.fields ?? []); + addItem(parsedValue); }, [addItem, field, multipleDefault], ); @@ -254,7 +276,7 @@ const ListControl: FC> = ({ newValue.splice(index, 1); setKeys(newKeys); - onChange(newValue); + onChange(newValue as string[] | ObjectValue[]); }, [onChange, internalValue, keys], ); @@ -278,7 +300,11 @@ const ListControl: FC> = ({ // Update value setKeys(arrayMoveImmutable(keys, oldIndex, newIndex)); - onChange(arrayMoveImmutable(internalValue, oldIndex, newIndex)); + onChange( + arrayMoveImmutable(internalValue, oldIndex, newIndex) as + | string[] + | ObjectValue[], + ); }, [onChange, internalValue, keys], ); @@ -306,6 +332,7 @@ const ListControl: FC> = ({ collapsed={collapsed} hasError={hasErrors} t={t} + testId="list-header" /> {internalValue.length > 0 ? ( @@ -325,7 +352,6 @@ const ListControl: FC> = ({ item={item} valueType={valueType} handleRemove={handleRemove} - data-testid={`object-control-${index}`} entry={entry} field={field} fieldsErrors={fieldsErrors} diff --git a/packages/core/src/widgets/list/ListItem.tsx b/packages/core/src/widgets/list/ListItem.tsx index 97bc6b1b..d0d670f6 100644 --- a/packages/core/src/widgets/list/ListItem.tsx +++ b/packages/core/src/widgets/list/ListItem.tsx @@ -6,7 +6,7 @@ import EditorControl from '@staticcms/core/components/Editor/EditorControlPane/E import ListItemTopBar from '@staticcms/core/components/UI/ListItemTopBar'; import Outline from '@staticcms/core/components/UI/Outline'; import { colors } from '@staticcms/core/components/UI/styles'; -import { transientOptions } from '@staticcms/core/lib'; +import transientOptions from '@staticcms/core/lib/util/transientOptions'; import { addFileTemplateFields, compileStringTemplate, @@ -21,6 +21,7 @@ import type { ListField, ObjectField, ObjectValue, + ValueOrNestedValue, WidgetControlProps, } from '@staticcms/core/interface'; import type { FC, MouseEvent } from 'react'; @@ -55,19 +56,27 @@ const StyledObjectFieldWrapper = styled( `, ); -function handleSummary(summary: string, entry: Entry, label: string, item: ObjectValue) { - const labeledItem: EntryData = { - ...item, - fields: { - label, - }, - }; - const data = addFileTemplateFields(entry.path, labeledItem); - return compileStringTemplate(summary, null, '', data); +function handleSummary(summary: string, entry: Entry, label: string, item: ValueOrNestedValue) { + if (typeof item === 'object' && !Array.isArray(item)) { + const labeledItem: EntryData = { + ...item, + fields: { + label, + }, + }; + const data = addFileTemplateFields(entry.path, labeledItem); + return compileStringTemplate(summary, null, '', data); + } + + return item; } -function validateItem(field: ListField, item: ObjectValue) { - if (!(typeof item === 'object')) { +function validateItem(field: ListField, item: ValueOrNestedValue) { + if (field.fields && field.fields.length === 1) { + return true; + } + + if (typeof item !== 'object') { console.warn( `'${field.name}' field item value value should be an object but is a '${typeof item}'`, ); @@ -79,7 +88,7 @@ function validateItem(field: ListField, item: ObjectValue) { interface ListItemProps extends Pick< - WidgetControlProps, + WidgetControlProps, | 'entry' | 'field' | 'fieldsErrors' @@ -137,7 +146,9 @@ const ListItem: FC = ({ return [base, childObjectField]; } - const itemType = getTypedFieldForValue(field, objectValue, index); + const mixedObjectValue = objectValue as ObjectValue; + + const itemType = getTypedFieldForValue(field, mixedObjectValue, index); if (!itemType) { return [base, childObjectField]; } @@ -146,7 +157,7 @@ const ListItem: FC = ({ // each type can have its own summary, but default to the list summary if exists const summary = ('summary' in itemType && itemType.summary) ?? field.summary; const labelReturn = summary - ? `${label} - ${handleSummary(summary, entry, label, objectValue)}` + ? `${label} - ${handleSummary(summary, entry, label, mixedObjectValue)}` : label; return [labelReturn, itemType]; } @@ -163,7 +174,10 @@ const ListItem: FC = ({ return [base, childObjectField]; } - const labelFieldValue = objectValue[labelField.name]; + const labelFieldValue = + typeof objectValue === 'object' && !Array.isArray(objectValue) + ? objectValue[labelField.name] + : objectValue; const summary = field.summary; const labelReturn = summary @@ -186,6 +200,16 @@ const ListItem: FC = ({ const isDuplicate = isFieldDuplicate && isFieldDuplicate(field); const isHidden = isFieldHidden && isFieldHidden(field); + const finalValue = useMemo(() => { + if (field.fields && field.fields.length === 1) { + return { + [field.fields[0].name]: value, + }; + } + + return value; + }, [field.fields, value]); + return ( <> @@ -194,7 +218,7 @@ const ListItem: FC = ({ collapsed={collapsed} onCollapseToggle={handleCollapseToggle} onRemove={partial(handleRemove, index)} - data-testid={`styled-list-item-top-bar-${id}`} + data-testid={`list-item-top-bar-${id}`} title={objectLabel} isVariableTypesList={valueType === ListValueType.MIXED} listeners={listeners} @@ -203,7 +227,7 @@ const ListItem: FC = ({ = ({ isFieldHidden={isFieldHidden} locale={locale} i18n={i18n} - forList + forList={true} /> diff --git a/packages/core/src/widgets/list/ListPreview.tsx b/packages/core/src/widgets/list/ListPreview.tsx index b7af2587..f571ac58 100644 --- a/packages/core/src/widgets/list/ListPreview.tsx +++ b/packages/core/src/widgets/list/ListPreview.tsx @@ -2,11 +2,26 @@ import React from 'react'; import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer'; -import type { ListField, ObjectValue, WidgetPreviewProps } from '@staticcms/core/interface'; +import type { ListField, ValueOrNestedValue, WidgetPreviewProps } from '@staticcms/core/interface'; import type { FC } from 'react'; -const ObjectPreview: FC> = ({ field }) => { - return {field.fields ?? null}; +const ListPreview: FC> = ({ field, value }) => { + if (field.fields && field.fields.length === 1) { + return ( + + +
    + {value?.map(item => ( +
  • {String(item)}
  • + ))} +
+
+ ); + } + + return {field.renderedFields ?? null}; }; -export default ObjectPreview; +export default ListPreview; diff --git a/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx b/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx new file mode 100644 index 00000000..0541d2e4 --- /dev/null +++ b/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx @@ -0,0 +1,220 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom'; +import { getByTestId, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import createControlWrapper from '@staticcms/core/lib/test-utils/ControlWrapper'; +import ListControl from '../ListControl'; + +import type { ListField } from '@staticcms/core/interface'; + +const singletonListField: ListField = { + widget: 'list', + name: 'singleton', + fields: [ + { + widget: 'string', + name: 'stringInput', + default: 'string default', + }, + ], +}; + +const multipleFieldsListField: ListField = { + widget: 'list', + name: 'multipleFields', + fields: [ + { + widget: 'string', + name: 'stringInput', + default: 'string default', + }, + { + widget: 'text', + name: 'textInput', + default: 'text default', + }, + ], +}; + +const multipleFieldsValue = [ + { stringInput: 'String Value 1', textInput: 'Text Value 1' }, + { stringInput: 'String Value 2', textInput: 'Text Value 2' }, +]; + +const ListControlWrapper = createControlWrapper({ + defaultField: singletonListField, + control: ListControl, + label: 'List Control', + path: 'list', +}); + +jest.mock('@staticcms/core/components/Editor/EditorControlPane/EditorControl', () => { + return jest.fn(props => { + const { parentPath, field, value } = props; + return ( +
+
{parentPath}
+
{field.name}
+
{JSON.stringify(value)}
+
+ ); + }); +}); + +describe(ListControl.name, () => { + describe('multiple field list', () => { + it('renders empty div by default', () => { + render(); + expect(screen.getByTestId('object-control-0')).not.toBeVisible(); + expect(screen.getByTestId('object-control-1')).not.toBeVisible(); + }); + + it('renders values when opened', async () => { + render(); + + const headerBar = screen.getByTestId('list-header'); + + await userEvent.click(getByTestId(headerBar, 'expand-button')); + + const itemOne = screen.getByTestId('object-control-0'); + expect(itemOne).toBeVisible(); + expect(getByTestId(itemOne, 'list-item-title').textContent).toBe('String Value 1'); + expect(getByTestId(itemOne, 'parentPath').textContent).toBe('list'); + expect(getByTestId(itemOne, 'fieldName').textContent).toBe('0'); + expect(JSON.parse(getByTestId(itemOne, 'value').textContent ?? '')).toEqual({ + stringInput: 'String Value 1', + textInput: 'Text Value 1', + }); + + const itemTwo = screen.getByTestId('object-control-1'); + expect(itemTwo).toBeVisible(); + expect(getByTestId(itemTwo, 'list-item-title').textContent).toBe('String Value 2'); + expect(getByTestId(itemTwo, 'parentPath').textContent).toBe('list'); + expect(getByTestId(itemTwo, 'fieldName').textContent).toBe('1'); + expect(JSON.parse(getByTestId(itemTwo, 'value').textContent ?? '')).toEqual({ + stringInput: 'String Value 2', + textInput: 'Text Value 2', + }); + }); + + it('outputs value as object array when adding new value', async () => { + const onChange = jest.fn(); + + render( + , + ); + + const headerBar = screen.getByTestId('list-header'); + + await userEvent.click(getByTestId(headerBar, 'expand-button')); + + await userEvent.click(getByTestId(headerBar, 'add-button')); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith([ + ...multipleFieldsValue, + { + stringInput: 'string default', + textInput: 'text default', + }, + ]); + }); + + it('outputs value as object array when removing existing value', async () => { + const onChange = jest.fn(); + + render( + , + ); + + const headerBar = screen.getByTestId('list-header'); + + await userEvent.click(getByTestId(headerBar, 'expand-button')); + + const itemOne = screen.getByTestId('object-control-0'); + await userEvent.click(getByTestId(itemOne, 'remove-button')); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith([ + { stringInput: 'String Value 2', textInput: 'Text Value 2' }, + ]); + }); + }); + + describe('singleton list', () => { + it('renders empty div by default', () => { + render(); + expect(screen.getByTestId('object-control-0')).not.toBeVisible(); + expect(screen.getByTestId('object-control-1')).not.toBeVisible(); + }); + + it('renders values when opened', async () => { + render(); + + const headerBar = screen.getByTestId('list-header'); + + await userEvent.click(getByTestId(headerBar, 'expand-button')); + + const itemOne = screen.getByTestId('object-control-0'); + expect(itemOne).toBeVisible(); + expect(getByTestId(itemOne, 'list-item-title').textContent).toBe('Value 1'); + expect(getByTestId(itemOne, 'parentPath').textContent).toBe('list'); + expect(getByTestId(itemOne, 'fieldName').textContent).toBe('0'); + expect(JSON.parse(getByTestId(itemOne, 'value').textContent ?? '')).toEqual({ + stringInput: 'Value 1', + }); + + const itemTwo = screen.getByTestId('object-control-1'); + expect(itemTwo).toBeVisible(); + expect(getByTestId(itemTwo, 'list-item-title').textContent).toBe('Value 2'); + expect(getByTestId(itemTwo, 'parentPath').textContent).toBe('list'); + expect(getByTestId(itemTwo, 'fieldName').textContent).toBe('1'); + expect(JSON.parse(getByTestId(itemTwo, 'value').textContent ?? '')).toEqual({ + stringInput: 'Value 2', + }); + }); + + it('outputs value as singleton array when adding new value', async () => { + const onChange = jest.fn(); + + render(); + + const headerBar = screen.getByTestId('list-header'); + + await userEvent.click(getByTestId(headerBar, 'expand-button')); + + await userEvent.click(getByTestId(headerBar, 'add-button')); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(['Value 1', 'Value 2', 'string default']); + }); + + it('outputs value as singleton array when removing existing value', async () => { + const onChange = jest.fn(); + + render(); + + const headerBar = screen.getByTestId('list-header'); + + await userEvent.click(getByTestId(headerBar, 'expand-button')); + + const itemOne = screen.getByTestId('object-control-0'); + await userEvent.click(getByTestId(itemOne, 'remove-button')); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(['Value 2']); + }); + }); +}); diff --git a/packages/core/src/widgets/list/index.ts b/packages/core/src/widgets/list/index.ts index 3f01986a..3a842a2d 100644 --- a/packages/core/src/widgets/list/index.ts +++ b/packages/core/src/widgets/list/index.ts @@ -2,9 +2,9 @@ import controlComponent from './ListControl'; import previewComponent from './ListPreview'; import schema from './schema'; -import type { ListField, ObjectValue, WidgetParam } from '@staticcms/core/interface'; +import type { ListField, ValueOrNestedValue, WidgetParam } from '@staticcms/core/interface'; -const ListWidget = (): WidgetParam => { +const ListWidget = (): WidgetParam => { return { name: 'list', controlComponent, diff --git a/packages/core/src/widgets/object/ObjectControl.tsx b/packages/core/src/widgets/object/ObjectControl.tsx index 334314da..6e3797fc 100644 --- a/packages/core/src/widgets/object/ObjectControl.tsx +++ b/packages/core/src/widgets/object/ObjectControl.tsx @@ -78,9 +78,16 @@ const ObjectControl: FC> = ({ const renderedField = useMemo(() => { return ( multiFields?.map((field, index) => { - const fieldName = field.name; + let fieldName = field.name; + let parentPath = path; const fieldValue = value && value[fieldName]; + if (forList && multiFields.length === 1) { + const splitPath = path.split('.'); + fieldName = splitPath.pop() ?? field.name; + parentPath = splitPath.join('.'); + } + const isDuplicate = isFieldDuplicate && isFieldDuplicate(field); const isHidden = isFieldHidden && isFieldHidden(field); @@ -88,10 +95,11 @@ const ObjectControl: FC> = ({ > = ({ ); }, [ fieldsErrors, + forList, i18n, isFieldDuplicate, isFieldHidden, @@ -125,6 +134,7 @@ const ObjectControl: FC> = ({ heading={objectLabel} hasError={hasErrors} t={t} + testId="object-title" /> )} diff --git a/packages/core/src/widgets/object/ObjectPreview.tsx b/packages/core/src/widgets/object/ObjectPreview.tsx index f4b7b2f8..69772470 100644 --- a/packages/core/src/widgets/object/ObjectPreview.tsx +++ b/packages/core/src/widgets/object/ObjectPreview.tsx @@ -6,7 +6,7 @@ import type { ObjectField, ObjectValue, WidgetPreviewProps } from '@staticcms/co import type { FC } from 'react'; const ObjectPreview: FC> = ({ field }) => { - return {field.fields ?? null}; + return {field.renderedFields ?? null}; }; export default ObjectPreview; diff --git a/packages/core/src/widgets/object/__tests__/ObjectControl.spec.tsx b/packages/core/src/widgets/object/__tests__/ObjectControl.spec.tsx new file mode 100644 index 00000000..9a8ace04 --- /dev/null +++ b/packages/core/src/widgets/object/__tests__/ObjectControl.spec.tsx @@ -0,0 +1,98 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom'; +import { getByTestId, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import createControlWrapper from '@staticcms/core/lib/test-utils/ControlWrapper'; +import ObjectControl from '../ObjectControl'; + +import type { ObjectField } from '@staticcms/core/interface'; + +const singleFieldObjectField: ObjectField = { + widget: 'object', + name: 'object_field', + label: 'Object Field', + fields: [ + { + widget: 'string', + name: 'stringInput', + default: 'string default', + }, + ], +}; + +const singleFieldObjectValue = { + stringInput: 'String Value', +}; + +const ObjectControlWrapper = createControlWrapper({ + defaultField: singleFieldObjectField, + control: ObjectControl, + label: 'Object Control', + path: 'object', +}); + +jest.mock('@staticcms/core/components/Editor/EditorControlPane/EditorControl', () => { + return jest.fn(props => { + const { parentPath, fieldName, field } = props; + return ( +
+
{parentPath}
+
{fieldName ?? field.name}
+
+ ); + }); +}); + +describe(ObjectControl.name, () => { + it('renders all fields visible by default', () => { + render(); + + expect(screen.getByTestId('object-title').textContent).toBe('Object Field'); + + const fields = screen.getAllByTestId('editor-control'); + expect(fields.length).toBe(1); + + const fieldOne = fields[0]; + expect(fieldOne).toBeVisible(); + expect(getByTestId(fieldOne, 'parentPath').textContent).toBe('object'); + expect(getByTestId(fieldOne, 'fieldName').textContent).toBe('stringInput'); + }); + + it('does not render fields when closed', async () => { + render(); + + await userEvent.click(screen.getByTestId('expand-button')); + + const fields = screen.getAllByTestId('editor-control'); + expect(fields.length).toBe(1); + + const fieldOne = fields[0]; + expect(fieldOne).not.toBeVisible(); + }); + + describe('for list', () => { + it('should pass down parent path and field name to child if for list and single field', () => { + render( + , + ); + + expect(screen.queryByTestId('object-title')).not.toBeInTheDocument(); + + const fields = screen.getAllByTestId('editor-control'); + expect(fields.length).toBe(1); + + const fieldOne = fields[0]; + expect(getByTestId(fieldOne, 'parentPath').textContent).toBe('list'); + expect(getByTestId(fieldOne, 'fieldName').textContent).toBe('0'); + }); + }); +}); diff --git a/packages/core/src/widgets/relation/RelationControl.tsx b/packages/core/src/widgets/relation/RelationControl.tsx index a2618d1c..97ddeb95 100644 --- a/packages/core/src/widgets/relation/RelationControl.tsx +++ b/packages/core/src/widgets/relation/RelationControl.tsx @@ -1,7 +1,7 @@ -import * as fuzzy from 'fuzzy'; import Autocomplete from '@mui/material/Autocomplete'; import CircularProgress from '@mui/material/CircularProgress'; import TextField from '@mui/material/TextField'; +import * as fuzzy from 'fuzzy'; import find from 'lodash/find'; import get from 'lodash/get'; import uniqBy from 'lodash/uniqBy'; @@ -21,8 +21,8 @@ import { expandPath, extractTemplateVars, } from '@staticcms/core/lib/widgets/stringTemplate'; +import { selectCollection } from '@staticcms/core/reducers/selectors/collections'; import { useAppSelector } from '@staticcms/core/store/hooks'; -import { selectCollection } from '@staticcms/core/reducers/collections'; import type { FilterOptionsState } from '@mui/material/useAutocomplete'; import type { diff --git a/packages/core/src/widgets/string/StringControl.tsx b/packages/core/src/widgets/string/StringControl.tsx index 2a73443f..6371f0b0 100644 --- a/packages/core/src/widgets/string/StringControl.tsx +++ b/packages/core/src/widgets/string/StringControl.tsx @@ -22,7 +22,10 @@ const StringControl: FC> = ({ return ( +### Singleton List (List of Strings) + + +```yaml +name: sections +label: Home Section +widget: list +default: + - 'tag-1' + - 'tag-2' +fields: + - name: tag + label: Tag + widget: string +``` + +```js +name: 'sections', +label: 'Home Section', +widget: 'list', +default: [ + 'tag-1', + 'tag-2' +] +fields: [ + { + name: 'tag', + label: 'Tag', + widget: 'string', + }, +] +``` + + + ### Typed List diff --git a/yarn.lock b/yarn.lock index 3802a432..b2f87195 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3476,7 +3476,7 @@ dependencies: tslib "^2.4.0" -"@testing-library/dom@^8.5.0": +"@testing-library/dom@8.19.1", "@testing-library/dom@^8.5.0": version "8.19.1" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.19.1.tgz#0e2dafd281dedb930bb235eac1045470b4129d0e" integrity sha512-P6iIPyYQ+qH8CvGauAqanhVnjrnRe0IZFSYCeGkSRW9q3u8bdVn2NPI+lasFyVsEQn1J/IFmp5Aax41+dAP9wg== @@ -3514,6 +3514,11 @@ "@testing-library/dom" "^8.5.0" "@types/react-dom" "^18.0.0" +"@testing-library/user-event@14.4.3": + version "14.4.3" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591" + integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q== + "@tippyjs/react@^4.2.6": version "4.2.6" resolved "https://registry.yarnpkg.com/@tippyjs/react/-/react-4.2.6.tgz#971677a599bf663f20bb1c60a62b9555b749cc71"