feat: singleton array list widget (#336)

This commit is contained in:
Daniel Lautzenheiser 2023-01-12 14:15:41 -05:00 committed by GitHub
parent a60d53b4ec
commit c5e94ed16d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
64 changed files with 1353 additions and 575 deletions

View File

@ -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

View File

@ -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",

View File

@ -0,0 +1 @@
export default jest.fn();

View File

@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export const createHashHistory = jest.fn();

View File

@ -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,

View File

@ -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<F extends BaseField = UnknownField>(field: Field<F>): 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 || [];

View File

@ -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<RootState, {}, AnyAction>, 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<RootState, {}, AnyAction>, 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<RootState, {}, AnyAction>, 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) {

View File

@ -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;
}

View File

@ -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));

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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';

View File

@ -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';

View File

@ -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<CollectionV
t,
} = ownProps;
const collection: Collection = name ? collections[name] : collections[0];
const sort = selectEntriesSort(state.entries, collection.name);
const sort = selectEntriesSort(state, collection.name);
const sortableFields = selectSortableFields(collection, t);
const viewFilters = selectViewFilters(collection);
const viewGroups = selectViewGroups(collection);
const filter = selectEntriesFilter(state.entries, collection.name);
const group = selectEntriesGroup(state.entries, collection.name);
const viewStyle = selectViewStyle(state.entries);
const filter = selectEntriesFilter(state, collection.name);
const group = selectEntriesGroup(state, collection.name);
const viewStyle = selectViewStyle(state);
return {
isSearchResults,

View File

@ -9,21 +9,21 @@ import {
} from '@staticcms/core/actions/entries';
import { colors } from '@staticcms/core/components/UI/styles';
import { Cursor } from '@staticcms/core/lib/util';
import { selectCollectionEntriesCursor } from '@staticcms/core/reducers/cursors';
import { selectCollectionEntriesCursor } from '@staticcms/core/reducers/selectors/cursors';
import {
selectEntries,
selectEntriesLoaded,
selectGroups,
selectIsFetching,
} from '@staticcms/core/reducers/entries';
} from '@staticcms/core/reducers/selectors/entries';
import Entries from './Entries';
import type { ComponentType } from 'react';
import type { t } from 'react-polyglot';
import type { ConnectedProps } from 'react-redux';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { ComponentType } from 'react';
import type { t } from 'react-polyglot';
import type { ConnectedProps } from 'react-redux';
const GroupHeading = styled('h2')`
font-size: 23px;
@ -163,18 +163,18 @@ function mapStateToProps(state: RootState, ownProps: EntriesCollectionOwnProps)
const { collection, viewStyle, filterTerm } = ownProps;
const page = state.entries.pages[collection.name]?.page;
let entries = selectEntries(state.entries, collection);
const groups = selectGroups(state.entries, collection);
let entries = selectEntries(state, collection);
const groups = selectGroups(state, collection);
if ('nested' in collection) {
const collectionFolder = collection.folder ?? '';
entries = filterNestedEntries(filterTerm || '', collectionFolder, entries);
}
const entriesLoaded = selectEntriesLoaded(state.entries, collection.name);
const isFetching = selectIsFetching(state.entries, collection.name);
const entriesLoaded = selectEntriesLoaded(state, collection.name);
const isFetching = selectIsFetching(state, collection.name);
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.name);
const rawCursor = selectCollectionEntriesCursor(state, collection.name);
const cursor = Cursor.create(rawCursor).clearData();
return { ...ownProps, page, entries, groups, entriesLoaded, isFetching, viewStyle, cursor };

View File

@ -7,13 +7,13 @@ import {
searchEntries as searchEntriesAction,
} from '@staticcms/core/actions/search';
import { Cursor } from '@staticcms/core/lib/util';
import { selectSearchedEntries } from '@staticcms/core/reducers';
import { selectSearchedEntries } from '@staticcms/core/reducers/selectors/entries';
import Entries from './Entries';
import type { ConnectedProps } from 'react-redux';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
import type { Collections } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { ConnectedProps } from 'react-redux';
const EntriesSearch = ({
collections,

View File

@ -11,7 +11,7 @@ import { getAsset as getAssetAction } from '@staticcms/core/actions/media';
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '@staticcms/core/constants/collectionViews';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util';
import { selectIsLoadingAsset } from '@staticcms/core/reducers/medias';
import { selectIsLoadingAsset } from '@staticcms/core/reducers/selectors/medias';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
import type { Collection, Entry, Field } from '@staticcms/core/interface';
@ -78,7 +78,7 @@ function mapStateToProps(state: RootState, ownProps: EntryCardOwnProps) {
image = encodeURI(image.trim());
}
const isLoadingAsset = selectIsLoadingAsset(state.medias);
const isLoadingAsset = selectIsLoadingAsset(state);
return {
...ownProps,

View File

@ -10,7 +10,7 @@ import { colors, components } from '@staticcms/core/components/UI/styles';
import { transientOptions } from '@staticcms/core/lib';
import { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util';
import { stringTemplate } from '@staticcms/core/lib/widgets';
import { selectEntries } from '@staticcms/core/reducers/entries';
import { selectEntries } from '@staticcms/core/reducers/selectors/entries';
import type { Collection, Entry } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
@ -344,7 +344,7 @@ interface NestedCollectionOwnProps {
function mapStateToProps(state: RootState, ownProps: NestedCollectionOwnProps) {
const { collection } = ownProps;
const entries = selectEntries(state.entries, collection) ?? [];
const entries = selectEntries(state, collection) ?? [];
return { ...ownProps, entries };
}

View File

@ -24,15 +24,12 @@ import {
} from '@staticcms/core/actions/scroll';
import { selectFields } from '@staticcms/core/lib/util/collection.util';
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import { selectEntry } from '@staticcms/core/reducers';
import { selectEntry } from '@staticcms/core/reducers/selectors/entries';
import { history, navigateToCollection, navigateToNewEntry } from '@staticcms/core/routing/history';
import confirm from '../UI/Confirm';
import Loader from '../UI/Loader';
import EditorInterface from './EditorInterface';
import type { Blocker } from 'history';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
import type {
Collection,
EditorPersistOptions,
@ -40,6 +37,9 @@ import type {
TranslatedProps,
} from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { Blocker } from 'history';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
const Editor = ({
entry,

View File

@ -25,8 +25,8 @@ import { resolveWidget } from '@staticcms/core/lib/registry';
import { getFieldLabel } from '@staticcms/core/lib/util/field.util';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { validate } from '@staticcms/core/lib/util/validation.util';
import { selectFieldErrors } from '@staticcms/core/reducers/entryDraft';
import { selectIsLoadingAsset } from '@staticcms/core/reducers/medias';
import { selectFieldErrors } from '@staticcms/core/reducers/selectors/entryDraft';
import { selectIsLoadingAsset } from '@staticcms/core/reducers/selectors/medias';
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
import type {
@ -160,6 +160,7 @@ const EditorControl = ({
forList = false,
changeDraftField,
i18n,
fieldName,
}: TranslatedProps<EditorControlProps>) => {
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,

View File

@ -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<string, unknown>) 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 };
}

View File

@ -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

View File

@ -117,12 +117,12 @@ const ListItemTopBar = ({
/>
</IconButton>
) : null}
<StyledTitle key="title" onClick={onCollapseToggle}>
<StyledTitle key="title" onClick={onCollapseToggle} data-testid="list-item-title">
{title}
</StyledTitle>
{listeners ? <DragHandle listeners={listeners} /> : null}
{onRemove ? (
<TopBarButton onClick={onRemove}>
<TopBarButton data-testid="remove-button" onClick={onRemove}>
<CloseIcon />
</TopBarButton>
) : null}

View File

@ -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<ObjectWidgetTopBarProps>) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
@ -129,6 +131,7 @@ const ObjectWidgetTopBar = ({
endIcon={<AddIcon fontSize="small" />}
size="small"
variant="outlined"
data-testid="add-button"
>
{t('editor.editorWidgets.list.add', { item: label })}
</Button>
@ -147,7 +150,7 @@ const ObjectWidgetTopBar = ({
}, [allowAdd, types, renderTypesDropdown, renderAddButton]);
return (
<TopBarContainer>
<TopBarContainer data-testid={testId}>
<ExpandButtonContainer $hasError={hasError}>
<IconButton onClick={onCollapseToggle} data-testid="expand-button">
<ExpandMoreIcon

View File

@ -1,3 +1,119 @@
export const SORT_DIRECTION_ASCENDING = 'Ascending';
export const SORT_DIRECTION_DESCENDING = 'Descending';
export const SORT_DIRECTION_NONE = 'None';
/**
* Local Storage
*/
export const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled';
/**
* Actions
*/
// Auth
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';
// Config
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
export const CONFIG_SUCCESS = 'CONFIG_SUCCESS';
export const CONFIG_FAILURE = 'CONFIG_FAILURE';
// Entries
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';
// Media
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';
// Media Library
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';
// Scroll
export const TOGGLE_SCROLL = 'TOGGLE_SCROLL';
export const SET_SCROLL = 'SET_SCROLL';
// Search
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';
// Status
export const STATUS_REQUEST = 'STATUS_REQUEST';
export const STATUS_SUCCESS = 'STATUS_SUCCESS';
export const STATUS_FAILURE = 'STATUS_FAILURE';
// Wait Until
export const WAIT_UNTIL_ACTION = 'WAIT_UNTIL_ACTION';

View File

@ -66,13 +66,12 @@ export type ObjectValue = {
export type ValueOrNestedValue =
| string
| number
| boolean
| string[]
| (string | number)[]
| null
| undefined
| boolean
| ObjectValue
| ObjectValue[];
| ValueOrNestedValue[]
| null
| undefined;
export type EntryData = ObjectValue | undefined | null;
@ -505,8 +504,8 @@ export type AuthScope = 'repo' | 'public_repo';
export type SlugEncoding = 'unicode' | 'ascii';
export type RenderedField<F extends BaseField = UnknownField> = Omit<F, 'fields'> & {
fields?: ReactNode[];
export type RenderedField<F extends BaseField = UnknownField> = F & {
renderedFields?: ReactNode[];
};
export interface BaseField {
@ -576,7 +575,7 @@ export interface ObjectField<EF extends BaseField = UnknownField> extends BaseFi
export interface ListField<EF extends BaseField = UnknownField> extends BaseField {
widget: 'list';
default?: ObjectValue[];
default?: ValueOrNestedValue[];
allow_add?: boolean;
collapsed?: boolean;
@ -756,7 +755,6 @@ export interface Config<EF extends BaseField = UnknownField> {
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;

View File

@ -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<T extends string | string[]>(
value: T,

View File

@ -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<V = unknown, F extends BaseField = UnknownField> {
defaultField: F;
control: FC<WidgetControlProps<V, F>>;
label: string;
path: string;
}
const createControlWrapper = <V = unknown, F extends BaseField = UnknownField>({
defaultField,
control: Control,
label: defaultLabel,
path: defaultPath,
}: CreateControlWrapper<V, F>) => {
const ControlWrapper: FC<Partial<WidgetControlProps<V, F>>> = ({
collection = {} as Collection<F>,
config = {} as Config<F>,
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 (
<Control
key="control"
collection={collection}
config={config}
entry={entry}
field={field}
fieldsErrors={fieldsErrors}
submitted={submitted}
forList={forList}
getAsset={getAsset}
isDisabled={isDisabled}
isFieldDuplicate={isFieldDuplicate}
isFieldHidden={isFieldHidden}
label={label}
locale={locale}
mediaPaths={mediaPaths}
onChange={onChange}
clearMediaControl={clearMediaControl}
openMediaLibrary={openMediaLibrary}
removeInsertedMedia={removeInsertedMedia}
removeMediaControl={removeMediaControl}
i18n={i18n}
hasErrors={hasErrors}
path={path}
query={query}
t={t}
value={value}
/>
);
};
return ControlWrapper;
};
export default createControlWrapper;

View File

@ -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;

View File

@ -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'],
});
});
});
});
});

View File

@ -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;

View File

@ -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);
};

View File

@ -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;

View File

@ -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;

View File

@ -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<string, { id: string; label: string; value: string | boolean | undefined }> =
{};
const groupedEntries = groupBy(entries, entry => {
const group = getGroup(entry, selectedGroup);
groups = { ...groups, [group.id]: group };
return group.id;
});
const groupsArray: GroupOfEntries[] = Object.entries(groupedEntries).map(([id, entries]) => {
return {
...groups[id],
paths: new Set(entries.map(entry => entry.path)),
};
});
return groupsArray;
}
export function selectEntryByPath(state: 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;

View File

@ -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] ?? [];
};

View File

@ -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));
}

View File

@ -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;

View File

@ -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;

View File

@ -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';

View File

@ -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';

View File

@ -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);
};

View File

@ -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';
}

View File

@ -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]);
}

View File

@ -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<string, { id: string; label: string; value: string | boolean | undefined }> =
{};
const groupedEntries = groupBy(entries, entry => {
const group = getGroup(entry, selectedGroup);
groups = { ...groups, [group.id]: group };
return group.id;
});
const groupsArray: GroupOfEntries[] = Object.entries(groupedEntries).map(([id, entries]) => {
return {
...groups[id],
paths: new Set(entries.map(entry => entry.path)),
};
});
return groupsArray;
}
export function selectEntryByPath(state: RootState, collection: string, path: string) {
const slugs = selectPublishedSlugs(state, collection);
const 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));
}

View File

@ -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;
}

View File

@ -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];
};

View File

@ -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);
}

View File

@ -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';

View File

@ -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;

View File

@ -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<SortableItemProps> = ({
}
return (
<div ref={setNodeRef} style={style} {...attributes}>
<div ref={setNodeRef} data-testid={`object-control-${index}`} style={style} {...attributes}>
<ListItem
index={index}
id={id}
@ -122,7 +122,7 @@ const SortableItem: FC<SortableItemProps> = ({
isFieldHidden={isFieldHidden}
locale={locale}
path={path}
value={item as Record<string, ObjectValue>}
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<WidgetControlProps<ObjectValue[], ListField>> = ({
const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({
entry,
field,
fieldsErrors,
@ -196,7 +217,7 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
}, []);
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<WidgetControlProps<ObjectValue[], ListField>> = ({
);
const addItem = useCallback(
(parsedValue: ObjectValue) => {
(parsedValue: ValueOrNestedValue) => {
const addToTop = field.add_to_top ?? false;
const newKeys = [...keys];
@ -221,7 +242,7 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
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<WidgetControlProps<ObjectValue[], ListField>> = ({
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<WidgetControlProps<ObjectValue[], ListField>> = ({
newValue.splice(index, 1);
setKeys(newKeys);
onChange(newValue);
onChange(newValue as string[] | ObjectValue[]);
},
[onChange, internalValue, keys],
);
@ -278,7 +300,11 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
// Update value
setKeys(arrayMoveImmutable(keys, oldIndex, newIndex));
onChange(arrayMoveImmutable(internalValue, oldIndex, newIndex));
onChange(
arrayMoveImmutable<ValueOrNestedValue>(internalValue, oldIndex, newIndex) as
| string[]
| ObjectValue[],
);
},
[onChange, internalValue, keys],
);
@ -306,6 +332,7 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
collapsed={collapsed}
hasError={hasErrors}
t={t}
testId="list-header"
/>
{internalValue.length > 0 ? (
<DndContext key="dnd-context" onDragEnd={handleDragEnd}>
@ -325,7 +352,6 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
item={item}
valueType={valueType}
handleRemove={handleRemove}
data-testid={`object-control-${index}`}
entry={entry}
field={field}
fieldsErrors={fieldsErrors}

View File

@ -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<ObjectValue, ListField>,
WidgetControlProps<ValueOrNestedValue, ListField>,
| 'entry'
| 'field'
| 'fieldsErrors'
@ -137,7 +146,9 @@ const ListItem: FC<ListItemProps> = ({
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<ListItemProps> = ({
// 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<ListItemProps> = ({
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<ListItemProps> = ({
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 (
<StyledListItem key="sortable-list-item">
<>
@ -194,7 +218,7 @@ const ListItem: FC<ListItemProps> = ({
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<ListItemProps> = ({
<EditorControl
key={`control-${id}`}
field={objectField}
value={value}
value={finalValue}
fieldsErrors={fieldsErrors}
submitted={submitted}
parentPath={path}
@ -213,7 +237,7 @@ const ListItem: FC<ListItemProps> = ({
isFieldHidden={isFieldHidden}
locale={locale}
i18n={i18n}
forList
forList={true}
/>
</StyledObjectFieldWrapper>
<Outline key="outline" />

View File

@ -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<WidgetPreviewProps<ObjectValue[], ListField>> = ({ field }) => {
return <WidgetPreviewContainer>{field.fields ?? null}</WidgetPreviewContainer>;
const ListPreview: FC<WidgetPreviewProps<ValueOrNestedValue[], ListField>> = ({ field, value }) => {
if (field.fields && field.fields.length === 1) {
return (
<WidgetPreviewContainer>
<label>
<strong>{field.name}:</strong>
</label>
<ul style={{ marginTop: 0 }}>
{value?.map(item => (
<li key={String(item)}>{String(item)}</li>
))}
</ul>
</WidgetPreviewContainer>
);
}
return <WidgetPreviewContainer>{field.renderedFields ?? null}</WidgetPreviewContainer>;
};
export default ObjectPreview;
export default ListPreview;

View File

@ -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 (
<div data-testid="editor-control">
<div data-testid="parentPath">{parentPath}</div>
<div data-testid="fieldName">{field.name}</div>
<div data-testid="value">{JSON.stringify(value)}</div>
</div>
);
});
});
describe(ListControl.name, () => {
describe('multiple field list', () => {
it('renders empty div by default', () => {
render(<ListControlWrapper field={multipleFieldsListField} value={multipleFieldsValue} />);
expect(screen.getByTestId('object-control-0')).not.toBeVisible();
expect(screen.getByTestId('object-control-1')).not.toBeVisible();
});
it('renders values when opened', async () => {
render(<ListControlWrapper field={multipleFieldsListField} value={multipleFieldsValue} />);
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(
<ListControlWrapper
field={multipleFieldsListField}
value={multipleFieldsValue}
onChange={onChange}
/>,
);
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(
<ListControlWrapper
field={multipleFieldsListField}
value={multipleFieldsValue}
onChange={onChange}
/>,
);
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(<ListControlWrapper value={['Value 1', 'Value 2']} />);
expect(screen.getByTestId('object-control-0')).not.toBeVisible();
expect(screen.getByTestId('object-control-1')).not.toBeVisible();
});
it('renders values when opened', async () => {
render(<ListControlWrapper value={['Value 1', 'Value 2']} />);
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(<ListControlWrapper value={['Value 1', 'Value 2']} onChange={onChange} />);
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(<ListControlWrapper value={['Value 1', 'Value 2']} onChange={onChange} />);
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']);
});
});
});

View File

@ -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<ObjectValue[], ListField> => {
const ListWidget = (): WidgetParam<ValueOrNestedValue[], ListField> => {
return {
name: 'list',
controlComponent,

View File

@ -78,9 +78,16 @@ const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
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<WidgetControlProps<ObjectValue, ObjectField>> = ({
<EditorControl
key={index}
field={field}
fieldName={fieldName}
value={fieldValue}
fieldsErrors={fieldsErrors}
submitted={submitted}
parentPath={path}
parentPath={parentPath}
isDisabled={isDuplicate}
isHidden={isHidden}
isFieldDuplicate={isFieldDuplicate}
@ -104,6 +112,7 @@ const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
);
}, [
fieldsErrors,
forList,
i18n,
isFieldDuplicate,
isFieldHidden,
@ -125,6 +134,7 @@ const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
heading={objectLabel}
hasError={hasErrors}
t={t}
testId="object-title"
/>
)}
<StyledFieldsBox $collapsed={collapsed} key="object-control-fields">

View File

@ -6,7 +6,7 @@ import type { ObjectField, ObjectValue, WidgetPreviewProps } from '@staticcms/co
import type { FC } from 'react';
const ObjectPreview: FC<WidgetPreviewProps<ObjectValue, ObjectField>> = ({ field }) => {
return <WidgetPreviewContainer>{field.fields ?? null}</WidgetPreviewContainer>;
return <WidgetPreviewContainer>{field.renderedFields ?? null}</WidgetPreviewContainer>;
};
export default ObjectPreview;

View File

@ -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 (
<div data-testid="editor-control">
<div data-testid="parentPath">{parentPath}</div>
<div data-testid="fieldName">{fieldName ?? field.name}</div>
</div>
);
});
});
describe(ObjectControl.name, () => {
it('renders all fields visible by default', () => {
render(<ObjectControlWrapper field={singleFieldObjectField} value={singleFieldObjectValue} />);
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(<ObjectControlWrapper field={singleFieldObjectField} value={singleFieldObjectValue} />);
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(
<ObjectControlWrapper
field={singleFieldObjectField}
value={singleFieldObjectValue}
path="list.0"
forList={true}
/>,
);
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');
});
});
});

View File

@ -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 {

View File

@ -22,7 +22,10 @@ const StringControl: FC<WidgetControlProps<string, StringOrTextField>> = ({
return (
<TextField
key="string-control-input"
key="string-widget-control-input"
inputProps={{
'data-testid': 'string-widget-control-input',
}}
label={label}
variant="outlined"
value={internalValue}

View File

@ -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

View File

@ -406,6 +406,41 @@ fields: [
</CodeTabs>
### Singleton List (List of Strings)
<CodeTabs>
```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',
},
]
```
</CodeTabs>
### Typed List
<CodeTabs>

View File

@ -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"