feat: singleton array list widget (#336)
This commit is contained in:
parent
a60d53b4ec
commit
c5e94ed16d
@ -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
|
||||
|
@ -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",
|
||||
|
1
packages/core/src/__mocks__/array-move.ts
Normal file
1
packages/core/src/__mocks__/array-move.ts
Normal file
@ -0,0 +1 @@
|
||||
export default jest.fn();
|
2
packages/core/src/__mocks__/history.ts
Normal file
2
packages/core/src/__mocks__/history.ts
Normal file
@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const createHashHistory = jest.fn();
|
@ -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,
|
||||
|
@ -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 || [];
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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));
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
|
@ -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 };
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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 };
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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 };
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
101
packages/core/src/lib/test-utils/ControlWrapper.tsx
Normal file
101
packages/core/src/lib/test-utils/ControlWrapper.tsx
Normal 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;
|
17
packages/core/src/lib/test-utils/mock-data/MockEntry.ts
Normal file
17
packages/core/src/lib/test-utils/mock-data/MockEntry.ts
Normal 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;
|
77
packages/core/src/reducers/__tests__/entryDraft.spec.ts
Normal file
77
packages/core/src/reducers/__tests__/entryDraft.spec.ts
Normal 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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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] ?? [];
|
||||
};
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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';
|
||||
|
||||
|
6
packages/core/src/reducers/selectors/collections.ts
Normal file
6
packages/core/src/reducers/selectors/collections.ts
Normal 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);
|
||||
};
|
7
packages/core/src/reducers/selectors/config.ts
Normal file
7
packages/core/src/reducers/selectors/config.ts
Normal 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';
|
||||
}
|
12
packages/core/src/reducers/selectors/cursors.ts
Normal file
12
packages/core/src/reducers/selectors/cursors.ts
Normal 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]);
|
||||
}
|
190
packages/core/src/reducers/selectors/entries.ts
Normal file
190
packages/core/src/reducers/selectors/entries.ts
Normal 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));
|
||||
}
|
10
packages/core/src/reducers/selectors/entryDraft.ts
Normal file
10
packages/core/src/reducers/selectors/entryDraft.ts
Normal 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;
|
||||
}
|
44
packages/core/src/reducers/selectors/mediaLibrary.ts
Normal file
44
packages/core/src/reducers/selectors/mediaLibrary.ts
Normal 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];
|
||||
};
|
7
packages/core/src/reducers/selectors/medias.ts
Normal file
7
packages/core/src/reducers/selectors/medias.ts
Normal 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);
|
||||
}
|
@ -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';
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
|
@ -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" />
|
||||
|
@ -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;
|
||||
|
220
packages/core/src/widgets/list/__tests__/ListControl.spec.tsx
Normal file
220
packages/core/src/widgets/list/__tests__/ListControl.spec.tsx
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
|
@ -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">
|
||||
|
@ -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;
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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 {
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user