refactor: monorepo setup with lerna (#243)

This commit is contained in:
Daniel Lautzenheiser
2022-12-15 13:44:49 -05:00
committed by GitHub
parent dac29fbf3c
commit 504d95c34f
706 changed files with 16571 additions and 142 deletions

View File

@ -0,0 +1,46 @@
import { produce } from 'immer';
import {
AUTH_REQUEST,
AUTH_SUCCESS,
AUTH_FAILURE,
AUTH_REQUEST_DONE,
LOGOUT,
} from '../actions/auth';
import type { User } from '../interface';
import type { AuthAction } from '../actions/auth';
export type AuthState = {
isFetching: boolean;
user: User | undefined;
error: string | undefined;
};
export const defaultState: AuthState = {
isFetching: false,
user: undefined,
error: undefined,
};
const auth = produce((state: AuthState, action: AuthAction) => {
switch (action.type) {
case AUTH_REQUEST:
state.isFetching = true;
break;
case AUTH_SUCCESS:
state.user = action.payload;
break;
case AUTH_FAILURE:
state.error = action.payload && action.payload.toString();
break;
case AUTH_REQUEST_DONE:
state.isFetching = false;
break;
case LOGOUT:
state.user = undefined;
state.isFetching = false;
}
}, defaultState);
export default auth;

View File

@ -0,0 +1,32 @@
import { CONFIG_SUCCESS } from '../actions/config';
import type { ConfigAction } from '../actions/config';
import type { Collection, Collections } from '../interface';
import type { RootState } from '../store';
export type CollectionsState = Collections;
const defaultState: CollectionsState = {};
function collections(
state: CollectionsState = defaultState,
action: ConfigAction,
): CollectionsState {
switch (action.type) {
case CONFIG_SUCCESS: {
const collections = action.payload.collections;
return collections.reduce((acc, collection) => {
acc[collection.name] = collection as Collection;
return acc;
}, {} as Record<string, Collection>);
}
default:
return state;
}
}
export default collections;
export const selectCollection = (collectionName: string) => (state: RootState) => {
return Object.values(state.collections).find(collection => collection.name === collectionName);
};

View File

@ -0,0 +1,13 @@
import { combineReducers } from 'redux';
import snackbarReducer from '../store/slices/snackbars';
import reducers from './index';
function createRootReducer() {
return combineReducers({
...reducers,
snackbar: snackbarReducer,
});
}
export default createRootReducer;

View File

@ -0,0 +1,39 @@
import { produce } from 'immer';
import { CONFIG_FAILURE, CONFIG_REQUEST, CONFIG_SUCCESS } from '../actions/config';
import type { ConfigAction } from '../actions/config';
import type { Config } from '../interface';
export interface ConfigState {
config?: Config;
isFetching: boolean;
error?: string;
}
const defaultState: ConfigState = {
isFetching: true,
};
const config = produce((state: ConfigState, action: ConfigAction) => {
switch (action.type) {
case CONFIG_REQUEST:
state.isFetching = true;
break;
case CONFIG_SUCCESS:
return {
config: action.payload,
isFetching: false,
error: undefined,
};
case CONFIG_FAILURE:
state.isFetching = false;
state.error = action.payload.toString();
}
}, defaultState);
export function selectLocale(state?: Config) {
return state?.locale || 'en';
}
export default config;

View File

@ -0,0 +1,60 @@
import {
ENTRIES_SUCCESS,
FILTER_ENTRIES_SUCCESS,
GROUP_ENTRIES_SUCCESS,
SORT_ENTRIES_SUCCESS,
} from '../actions/entries';
import { Cursor } from '../lib/util';
import type { EntriesAction } from '../actions/entries';
import type { CursorStore } from '../lib/util/Cursor';
export interface CursorsState {
cursorsByType: {
collectionEntries: Record<string, CursorStore>;
};
}
function cursors(
state: CursorsState = { cursorsByType: { collectionEntries: {} } },
action: EntriesAction,
): CursorsState {
switch (action.type) {
case ENTRIES_SUCCESS: {
return {
cursorsByType: {
collectionEntries: {
...state.cursorsByType.collectionEntries,
[action.payload.collection]: Cursor.create(action.payload.cursor).store,
},
},
};
}
case FILTER_ENTRIES_SUCCESS:
case GROUP_ENTRIES_SUCCESS:
case SORT_ENTRIES_SUCCESS: {
const newCollectionEntries = {
...state.cursorsByType.collectionEntries,
};
delete newCollectionEntries[action.payload.collection];
return {
cursorsByType: {
collectionEntries: newCollectionEntries,
},
};
}
default:
return state;
}
}
// Since pagination can be used for a variety of views (collections
// and searches are the most common examples), we namespace cursors by
// their type before storing them in the state.
export function selectCollectionEntriesCursor(state: CursorsState, collectionName: string) {
return new Cursor(state.cursorsByType.collectionEntries[collectionName]);
}
export default cursors;

View File

@ -0,0 +1,717 @@
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 {
CHANGE_VIEW_STYLE,
ENTRIES_FAILURE,
ENTRIES_REQUEST,
ENTRIES_SUCCESS,
ENTRY_DELETE_SUCCESS,
ENTRY_FAILURE,
ENTRY_REQUEST,
ENTRY_SUCCESS,
FILTER_ENTRIES_FAILURE,
FILTER_ENTRIES_REQUEST,
FILTER_ENTRIES_SUCCESS,
GROUP_ENTRIES_FAILURE,
GROUP_ENTRIES_REQUEST,
GROUP_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';
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';
type StorageSortObject = SortObject & { index: number };
type StorageSort = { [collection: string]: { [key: string]: StorageSortObject } };
const loadSort = once(() => {
const sortString = localStorage.getItem(storageSortKey);
if (sortString) {
try {
const sort: StorageSort = JSON.parse(sortString);
const map: Sort = {};
Object.entries(sort).forEach(([collection, sort]) => {
const orderedMap: SortMap = {};
sortBy(Object.values(sort), ['index']).forEach(value => {
const { key, direction } = value;
orderedMap[key] = { key, direction };
});
map[collection] = orderedMap;
});
return map;
} catch (e: unknown) {
return {} as Sort;
}
}
return {} as Sort;
});
function clearSort() {
localStorage.removeItem(storageSortKey);
}
function persistSort(sort: Sort | undefined) {
if (sort) {
const storageSort: StorageSort = {};
Object.keys(sort).forEach(key => {
const collection = key as string;
const sortObjects = (
(sort[collection] ? Object.values(sort[collection]) : []) as SortObject[]
).map((value, index) => ({ ...value, index }));
sortObjects.forEach(value => {
set(storageSort, `${collection}.${value.key}`, value);
});
});
localStorage.setItem(storageSortKey, JSON.stringify(storageSort));
} else {
clearSort();
}
}
const loadViewStyle = once(() => {
const viewStyle = localStorage.getItem(viewStyleKey) as CollectionViewStyle;
if (viewStyle) {
return viewStyle;
}
localStorage.setItem(viewStyleKey, VIEW_STYLE_LIST);
return VIEW_STYLE_LIST;
});
function clearViewStyle() {
localStorage.removeItem(viewStyleKey);
}
function persistViewStyle(viewStyle: string | undefined) {
if (viewStyle) {
localStorage.setItem(viewStyleKey, viewStyle);
} else {
clearViewStyle();
}
}
export type EntriesState = {
pages: Pages;
entities: Entities;
sort: Sort;
filter?: Filter;
group?: Group;
viewStyle: CollectionViewStyle;
};
function entries(
state: EntriesState = { entities: {}, pages: {}, sort: loadSort(), viewStyle: loadViewStyle() },
action: EntriesAction | SearchAction,
): EntriesState {
switch (action.type) {
case ENTRY_REQUEST: {
const payload = action.payload;
const key = `${payload.collection}.${payload.slug}`;
const newEntity: Entry = {
...(state.entities[key] ?? {}),
};
newEntity.isFetching = true;
return {
...state,
entities: {
...state.entities,
[key]: newEntity,
},
};
}
case ENTRY_SUCCESS: {
const payload = action.payload;
return {
...state,
entities: {
...state.entities,
[`${payload.collection}.${payload.entry.slug}`]: payload.entry,
},
};
}
case ENTRIES_REQUEST: {
const payload = action.payload;
const pages = {
...state.pages,
};
if (payload.collection in pages) {
const newCollection = {
...(pages[payload.collection] ?? {}),
};
newCollection.isFetching = true;
pages[payload.collection] = newCollection;
}
return { ...state, pages };
}
case ENTRIES_SUCCESS: {
const payload = action.payload;
const loadedEntries = payload.entries;
const page = payload.page;
const entities = {
...state.entities,
};
loadedEntries.forEach(entry => {
entities[`${payload.collection}.${entry.slug}`] = { ...entry, isFetching: false };
});
const pages = {
...state.pages,
};
pages[payload.collection] = {
page: page ?? undefined,
ids: [...(pages[payload.collection]?.ids ?? []), ...loadedEntries.map(entry => entry.slug)],
isFetching: false,
};
return { ...state, entities, pages };
}
case ENTRIES_FAILURE: {
const pages = {
...state.pages,
};
if (action.meta.collection in pages) {
const newCollection = {
...(pages[action.meta.collection] ?? {}),
};
newCollection.isFetching = false;
pages[action.meta.collection] = newCollection;
}
return { ...state, pages };
}
case ENTRY_FAILURE: {
const payload = action.payload;
const key = `${payload.collection}.${payload.slug}`;
return {
...state,
entities: {
...state.entities,
[key]: {
...(state.entities[key] ?? {}),
isFetching: false,
error: payload.error.message,
},
},
};
}
case SEARCH_ENTRIES_SUCCESS: {
const payload = action.payload;
const loadedEntries = payload.entries;
const entities = {
...state.entities,
};
loadedEntries.forEach(entry => {
entities[`${entry.collection}.${entry.slug}`] = {
...entry,
isFetching: false,
};
});
return { ...state, entities };
}
case ENTRY_DELETE_SUCCESS: {
const payload = action.payload;
const collection = payload.collectionName;
const slug = payload.entrySlug;
const entities = {
...state.entities,
};
delete entities[`${collection}.${slug}`];
const pages = {
...state.pages,
};
const newPagesCollection = {
...(pages[collection] ?? {}),
};
if (!newPagesCollection.ids) {
newPagesCollection.ids = [];
}
newPagesCollection.ids = newPagesCollection.ids.filter(
(id: string) => id !== payload.entrySlug,
);
pages[collection] = newPagesCollection;
return {
...state,
entities,
pages,
};
}
case SORT_ENTRIES_REQUEST: {
const payload = action.payload;
const { collection, key, direction } = payload;
const sort = {
...state.sort,
};
sort[collection] = { [key]: { key, direction } } as SortMap;
const pages = {
...state.pages,
};
const newPagesCollection = {
...(pages[collection] ?? {}),
};
newPagesCollection.isFetching = true;
delete newPagesCollection.page;
pages[collection] = newPagesCollection;
persistSort(sort);
return {
...state,
sort,
pages,
};
}
case GROUP_ENTRIES_SUCCESS:
case FILTER_ENTRIES_SUCCESS:
case SORT_ENTRIES_SUCCESS: {
const payload = action.payload as { collection: string; entries: Entry[] };
const { collection, entries } = payload;
const entities = {
...state.entities,
};
entries.forEach(entry => {
entities[`${entry.collection}.${entry.slug}`] = {
...entry,
isFetching: false,
};
});
const pages = {
...state.pages,
};
const ids = entries.map(entry => entry.slug);
pages[collection] = {
page: 1,
ids,
isFetching: false,
};
return {
...state,
entities,
pages,
};
}
case SORT_ENTRIES_FAILURE: {
const payload = action.payload;
const { collection, key } = payload;
const sort = {
...state.sort,
};
const newSortCollection = {
...(sort[collection] ?? {}),
};
delete newSortCollection[key];
sort[collection] = newSortCollection;
const pages = {
...state.pages,
};
const newPagesCollection = {
...(pages[collection] ?? {}),
};
newPagesCollection.isFetching = false;
delete newPagesCollection.page;
pages[collection] = newPagesCollection;
persistSort(sort);
return {
...state,
sort,
pages,
};
}
case FILTER_ENTRIES_REQUEST: {
const payload = action.payload;
const { collection, filter: viewFilter } = payload;
const filter = {
...state.filter,
};
const newFilterCollection = {
...(filter[collection] ?? {}),
};
let newFilter: FilterMap;
if (viewFilter.id in newFilterCollection) {
newFilter = { ...newFilterCollection[viewFilter.id] };
} else {
newFilter = { ...viewFilter };
}
newFilter.active = !newFilter.active;
newFilterCollection[viewFilter.id] = newFilter;
filter[collection] = newFilterCollection;
return {
...state,
filter,
};
}
case FILTER_ENTRIES_FAILURE: {
const payload = action.payload;
const { collection, filter: viewFilter } = payload;
const filter = {
...state.filter,
};
const newFilterCollection = {
...(filter[collection] ?? {}),
};
delete newFilterCollection[viewFilter.id];
filter[collection] = newFilterCollection;
const pages = {
...state.pages,
};
const newPagesCollection = {
...(pages[collection] ?? {}),
};
newPagesCollection.isFetching = false;
pages[collection] = newPagesCollection;
return {
...state,
filter,
pages,
};
}
case GROUP_ENTRIES_REQUEST: {
const payload = action.payload;
const { collection, group: groupBy } = payload;
const group = {
...state.group,
};
let newGroup: GroupMap;
if (group[collection] && groupBy.id in group[collection]) {
newGroup = { ...group[collection][groupBy.id] };
} else {
newGroup = { ...groupBy };
}
newGroup.active = !newGroup.active;
group[collection] = {
[groupBy.id]: newGroup,
};
return {
...state,
group,
};
}
case GROUP_ENTRIES_FAILURE: {
const payload = action.payload;
const { collection, group: groupBy } = payload;
const group = {
...state.group,
};
const newGroupCollection = {
...(group[collection] ?? {}),
};
delete newGroupCollection[groupBy.id];
group[collection] = newGroupCollection;
const pages = {
...state.pages,
};
const newPagesCollection = {
...(pages[collection] ?? {}),
};
newPagesCollection.isFetching = false;
pages[collection] = newPagesCollection;
return {
...state,
group,
pages,
};
}
case CHANGE_VIEW_STYLE: {
const payload = action.payload;
const { style } = payload;
persistViewStyle(style);
return {
...state,
viewStyle: style,
};
}
default:
return state;
}
}
export function selectEntriesSort(entries: EntriesState, collection: string) {
const sort = entries.sort as Sort | undefined;
return sort?.[collection];
}
export function selectEntriesFilter(entries: EntriesState, collection: string) {
const filter = entries.filter as Filter | undefined;
return filter?.[collection] || {};
}
export function selectEntriesGroup(entries: EntriesState, collection: string) {
const group = entries.group as Group | undefined;
return group?.[collection] || {};
}
export function selectEntriesGroupField(entries: EntriesState, collection: string) {
const groups = selectEntriesGroup(entries, collection);
const value = Object.values(groups ?? {}).find(v => v?.active === true);
return value;
}
export function selectEntriesSortFields(entries: EntriesState, collection: string) {
const sort = selectEntriesSort(entries, collection);
const values = Object.values(sort ?? {}).filter(v => v?.direction !== SORT_DIRECTION_NONE) || [];
return values;
}
export function selectEntriesFilterFields(entries: EntriesState, collection: string) {
const filter = selectEntriesFilter(entries, collection);
const values = Object.values(filter ?? {}).filter(v => v?.active === true) || [];
return values;
}
export function selectViewStyle(entries: EntriesState): CollectionViewStyle {
return entries.viewStyle;
}
export function selectEntry(state: EntriesState, collection: string, slug: string) {
return state.entities[`${collection}.${slug}`];
}
export function selectPublishedSlugs(state: EntriesState, collection: string) {
return state.pages[collection]?.ids ?? [];
}
function getPublishedEntries(state: EntriesState, collectionName: string) {
const slugs = selectPublishedSlugs(state, collectionName);
const entries =
slugs && (slugs.map(slug => selectEntry(state, collectionName, slug as string)) as Entry[]);
return entries;
}
export function selectEntries(state: EntriesState, collection: Collection) {
const collectionName = collection.name;
let entries = getPublishedEntries(state, collectionName);
const sortFields = selectEntriesSortFields(state, collectionName);
if (sortFields && sortFields.length > 0) {
const keys = sortFields.map(v => selectSortDataPath(collection, v.key));
const orders = sortFields.map(v => (v.direction === SORT_DIRECTION_ASCENDING ? 'asc' : 'desc'));
entries = orderBy(entries, keys, orders);
}
const filters = selectEntriesFilterFields(state, collectionName);
if (filters && filters.length > 0) {
entries = entries.filter(e => {
const allMatched = filters.every(f => {
const pattern = f.pattern;
const field = f.field;
const data = e!.data || {};
const toMatch = get(data, field);
const matched = toMatch !== undefined && new RegExp(String(pattern)).test(String(toMatch));
return matched;
});
return allMatched;
});
}
return entries;
}
function getGroup(entry: Entry, selectedGroup: GroupMap) {
const label = selectedGroup.label;
const field = selectedGroup.field;
const fieldData = get(entry.data, field);
if (fieldData === undefined) {
return {
id: 'missing_value',
label,
value: fieldData,
};
}
const dataAsString = String(fieldData);
if (selectedGroup.pattern) {
const pattern = selectedGroup.pattern;
let value = '';
try {
const regex = new RegExp(pattern);
const matched = dataAsString.match(regex);
if (matched) {
value = matched[0];
}
} catch (e: unknown) {
console.warn(`Invalid view group pattern '${pattern}' for field '${field}'`, e);
}
return {
id: `${label}${value}`,
label,
value,
};
}
return {
id: `${label}${fieldData}`,
label,
value: typeof fieldData === 'boolean' ? fieldData : dataAsString,
};
}
export function selectGroups(state: EntriesState, collection: Collection) {
const collectionName = collection.name;
const entries = getPublishedEntries(state, collectionName);
const selectedGroup = selectEntriesGroupField(state, collectionName);
if (selectedGroup === undefined) {
return [];
}
let groups: Record<string, { id: string; label: string; value: string | boolean | undefined }> =
{};
const groupedEntries = groupBy(entries, entry => {
const group = getGroup(entry, selectedGroup);
groups = { ...groups, [group.id]: group };
return group.id;
});
const groupsArray: GroupOfEntries[] = Object.entries(groupedEntries).map(([id, entries]) => {
return {
...groups[id],
paths: new Set(entries.map(entry => entry.path)),
};
});
return groupsArray;
}
export function selectEntryByPath(state: EntriesState, collection: string, path: string) {
const slugs = selectPublishedSlugs(state, collection);
const entries =
slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as Entry[]);
return entries && entries.find(e => e?.path === path);
}
export function selectEntriesLoaded(state: EntriesState, collection: string) {
return !!state.pages[collection];
}
export function selectIsFetching(state: EntriesState, collection: string) {
return state.pages[collection]?.isFetching ?? false;
}
export function selectEditingDraft(state: EntryDraftState) {
return state.entry;
}
export default entries;

View File

@ -0,0 +1,307 @@
import get from 'lodash/get';
import isEqual from 'lodash/isEqual';
import { v4 as uuid } from 'uuid';
import {
ADD_DRAFT_ENTRY_MEDIA_FILE,
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,
ENTRY_DELETE_SUCCESS,
ENTRY_PERSIST_FAILURE,
ENTRY_PERSIST_REQUEST,
ENTRY_PERSIST_SUCCESS,
REMOVE_DRAFT_ENTRY_MEDIA_FILE,
} from '../actions/entries';
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;
entry?: Entry;
fieldsErrors: FieldsErrors;
hasChanged: boolean;
key: string;
localBackup?: {
entry: Entry;
};
}
const initialState: EntryDraftState = {
fieldsErrors: {},
hasChanged: false,
key: '',
};
function entryDraftReducer(
state: EntryDraftState = initialState,
action: EntriesAction,
): EntryDraftState {
switch (action.type) {
case DRAFT_CREATE_FROM_ENTRY: {
const newState = { ...state };
const entry: Entry = {
...action.payload.entry,
newRecord: false,
};
// Existing Entry
return {
...newState,
entry,
original: entry,
fieldsErrors: {},
hasChanged: false,
key: uuid(),
};
}
case DRAFT_CREATE_EMPTY: {
const newState = { ...state };
delete newState.localBackup;
const entry: Entry = {
...action.payload,
newRecord: true,
};
// New Entry
return {
...newState,
entry,
original: entry,
fieldsErrors: {},
hasChanged: false,
key: uuid(),
};
}
case DRAFT_CREATE_FROM_LOCAL_BACKUP: {
const backupDraftEntry = state.localBackup;
if (!backupDraftEntry) {
return state;
}
const backupEntry = backupDraftEntry?.['entry'];
const newState = { ...state };
delete newState.localBackup;
const entry: Entry = {
...backupEntry,
newRecord: !backupEntry?.path,
};
// Local Backup
return {
...state,
entry,
original: entry,
fieldsErrors: {},
hasChanged: true,
key: uuid(),
};
}
case DRAFT_CREATE_DUPLICATE_FROM_ENTRY: {
const newState = { ...state };
delete newState.localBackup;
const entry: Entry = {
...action.payload,
newRecord: true,
};
// Duplicate Entry
return {
...newState,
entry,
original: entry,
fieldsErrors: {},
hasChanged: true,
};
}
case DRAFT_DISCARD:
return initialState;
case DRAFT_LOCAL_BACKUP_RETRIEVED: {
const { entry } = action.payload;
const newState = {
entry,
};
return {
...state,
localBackup: newState,
};
}
case DRAFT_LOCAL_BACKUP_DELETE: {
const newState = { ...state };
delete newState.localBackup;
return newState;
}
case DRAFT_CHANGE_FIELD: {
let newState = { ...state };
if (!newState.entry) {
return state;
}
const { path, field, value, i18n } = action.payload;
const dataPath = (i18n && getDataPath(i18n.currentLocale, i18n.defaultLocale)) || ['data'];
newState = {
...newState,
entry: set(newState.entry, `${dataPath.join('.')}.${path}`, value),
};
if (i18n) {
newState = duplicateI18nFields(newState, field, i18n.locales, i18n.defaultLocale);
}
const newData = get(newState.entry, dataPath) ?? {};
return {
...newState,
hasChanged: !newState.original || !isEqual(newData, get(newState.original, dataPath)),
};
}
case DRAFT_VALIDATION_ERRORS: {
const { path, errors } = action.payload;
const fieldsErrors = { ...state.fieldsErrors };
if (errors.length === 0) {
delete fieldsErrors[path];
} else {
fieldsErrors[path] = action.payload.errors;
}
return {
...state,
fieldsErrors,
};
}
case ENTRY_PERSIST_REQUEST: {
if (!state.entry) {
return state;
}
return {
...state,
entry: {
...state.entry,
isPersisting: true,
},
};
}
case ENTRY_PERSIST_FAILURE: {
if (!state.entry) {
return state;
}
return {
...state,
entry: {
...state.entry,
isPersisting: false,
},
};
}
case ENTRY_PERSIST_SUCCESS: {
if (!state.entry) {
return state;
}
const newState = { ...state };
delete newState.localBackup;
const entry: Entry = {
...state.entry,
slug: action.payload.slug,
isPersisting: false,
};
return {
...newState,
hasChanged: false,
entry,
original: entry,
};
}
case ENTRY_DELETE_SUCCESS: {
if (!state.entry) {
return state;
}
const newState = { ...state };
delete newState.localBackup;
const entry: Entry = {
...state.entry,
isPersisting: false,
};
return {
...newState,
hasChanged: false,
entry,
original: entry,
};
}
case ADD_DRAFT_ENTRY_MEDIA_FILE: {
if (!state.entry) {
return state;
}
const mediaFiles = state.entry.mediaFiles.filter(file => file.id !== action.payload.id);
mediaFiles.unshift(action.payload);
return {
...state,
hasChanged: true,
entry: {
...state.entry,
mediaFiles,
},
};
}
case REMOVE_DRAFT_ENTRY_MEDIA_FILE: {
if (!state.entry) {
return state;
}
const mediaFiles = state.entry.mediaFiles.filter(file => file.id !== action.payload.id);
return {
...state,
hasChanged: true,
entry: {
...state.entry,
mediaFiles,
},
};
}
default:
return state;
}
}
export default entryDraftReducer;
export const selectFieldErrors = (path: string) => (state: RootState) => {
return state.entryDraft.fieldsErrors[path] ?? [];
};

View File

@ -0,0 +1,29 @@
import type { AnyAction } from 'redux';
export type GlobalUIState = {
isFetching: boolean;
};
const defaultState: GlobalUIState = {
isFetching: false,
};
/**
* Reducer for some global UI state that we want to share between components
*/
const globalUI = (state: GlobalUIState = defaultState, action: AnyAction): GlobalUIState => {
// Generic, global loading indicator
if (action.type.includes('REQUEST')) {
return {
isFetching: true,
};
} else if (action.type.includes('SUCCESS') || action.type.includes('FAILURE')) {
return {
isFetching: false,
};
}
return state;
};
export default globalUI;

View File

@ -0,0 +1,54 @@
import auth from './auth';
import collections from './collections';
import config from './config';
import cursors from './cursors';
import entries, * as fromEntries from './entries';
import entryDraft from './entryDraft';
import globalUI from './globalUI';
import mediaLibrary from './mediaLibrary';
import medias from './medias';
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,
config,
cursors,
entries,
entryDraft,
globalUI,
mediaLibrary,
medias,
scroll,
search,
status,
};
export default reducers;
/*
* Selectors
*/
export function selectEntry(state: RootState, collection: string, slug: string) {
return fromEntries.selectEntry(state.entries, collection, slug);
}
export function selectEntries(state: RootState, collection: Collection) {
return fromEntries.selectEntries(state.entries, collection);
}
export function selectPublishedSlugs(state: RootState, collection: string) {
return fromEntries.selectPublishedSlugs(state.entries, collection);
}
export function selectSearchedEntries(state: RootState, availableCollections: string[]) {
// only return search results for actually available collections
return state.search.entryIds
.filter(entryId => availableCollections.indexOf(entryId!.collection) !== -1)
.map(entryId => fromEntries.selectEntry(state.entries, entryId!.collection, entryId!.slug));
}

View File

@ -0,0 +1,327 @@
import get from 'lodash/get';
import { dirname } from 'path';
import { v4 as uuid } from 'uuid';
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 '../actions/mediaLibrary';
import { selectMediaFolder } from '../lib/util/media.util';
import { selectEditingDraft } from './entries';
import type { MediaLibraryAction } from '../actions/mediaLibrary';
import type { DisplayURLState, Field, MediaFile, MediaLibraryInstance } from '../interface';
import type { RootState } from '../store';
export interface MediaLibraryDisplayURL {
url?: string;
isFetching: boolean;
err?: unknown;
}
export type MediaLibraryState = {
isVisible: boolean;
showMediaButton: boolean;
controlMedia: Record<string, string | string[]>;
displayURLs: Record<string, MediaLibraryDisplayURL>;
externalLibrary?: MediaLibraryInstance;
controlID?: string;
page?: number;
files?: MediaFile[];
config: Record<string, unknown>;
field?: Field;
value?: string | string[];
replaceIndex?: number;
canInsert?: boolean;
isLoading?: boolean;
dynamicSearch?: boolean;
dynamicSearchActive?: boolean;
dynamicSearchQuery?: string;
forImage?: boolean;
isPersisting?: boolean;
isDeleting?: boolean;
hasNextPage?: boolean;
isPaginating?: boolean;
};
const defaultState: MediaLibraryState = {
isVisible: false,
showMediaButton: true,
controlMedia: {},
displayURLs: {},
config: {},
};
function mediaLibrary(
state: MediaLibraryState = defaultState,
action: MediaLibraryAction,
): MediaLibraryState {
switch (action.type) {
case MEDIA_LIBRARY_CREATE:
return {
...state,
externalLibrary: action.payload,
showMediaButton: action.payload.enableStandalone(),
};
case MEDIA_LIBRARY_OPEN: {
const { controlID, forImage, config, field, value, replaceIndex } = action.payload;
const libConfig = config || {};
return {
...state,
isVisible: true,
forImage: Boolean(forImage),
controlID,
canInsert: !!controlID,
config: libConfig,
field,
value,
replaceIndex,
};
}
case MEDIA_LIBRARY_CLOSE:
return {
...state,
isVisible: false,
};
case MEDIA_INSERT: {
const { mediaPath } = action.payload;
const controlID = state.controlID;
if (!controlID) {
return state;
}
const value = state.value;
if (!Array.isArray(value)) {
return {
...state,
controlMedia: {
...state.controlMedia,
[controlID]: mediaPath,
},
};
}
const replaceIndex = state.replaceIndex;
const mediaArray = Array.isArray(mediaPath) ? mediaPath : [mediaPath];
const valueArray = value as string[];
if (typeof replaceIndex == 'number') {
valueArray[replaceIndex] = mediaArray[0];
} else {
valueArray.push(...mediaArray);
}
return {
...state,
controlMedia: {
...state.controlMedia,
[controlID]: valueArray,
},
};
}
case MEDIA_REMOVE_INSERTED: {
const controlID = action.payload.controlID;
return {
...state,
controlMedia: {
...state.controlMedia,
[controlID]: '',
},
};
}
case MEDIA_LOAD_REQUEST:
return {
...state,
isLoading: true,
isPaginating: action.payload.page > 1,
};
case MEDIA_LOAD_SUCCESS: {
const { files = [], page, canPaginate, dynamicSearch, dynamicSearchQuery } = action.payload;
const filesWithKeys = files.map(file => ({ ...file, key: uuid() }));
return {
...state,
isLoading: false,
isPaginating: false,
page: page ?? 1,
hasNextPage: Boolean(canPaginate && files.length > 0),
dynamicSearch: Boolean(dynamicSearch),
dynamicSearchQuery: dynamicSearchQuery ?? '',
dynamicSearchActive: !!dynamicSearchQuery,
files:
page && page > 1 ? (state.files as MediaFile[]).concat(filesWithKeys) : filesWithKeys,
};
}
case MEDIA_LOAD_FAILURE: {
return {
...state,
isLoading: false,
};
}
case MEDIA_PERSIST_REQUEST:
return {
...state,
isPersisting: true,
};
case MEDIA_PERSIST_SUCCESS: {
const { file } = action.payload;
const fileWithKey = { ...file, key: uuid() };
const files = state.files as MediaFile[];
const updatedFiles = [fileWithKey, ...files];
return {
...state,
files: updatedFiles,
isPersisting: false,
};
}
case MEDIA_PERSIST_FAILURE: {
return {
...state,
isPersisting: false,
};
}
case MEDIA_DELETE_REQUEST:
return {
...state,
isDeleting: true,
};
case MEDIA_DELETE_SUCCESS: {
const { file } = action.payload;
const { key, id } = file;
const files = state.files as MediaFile[];
const updatedFiles = files.filter(file => (key ? file.key !== key : file.id !== id));
const displayURLs = {
...state.displayURLs,
};
delete displayURLs[id];
return {
...state,
files: updatedFiles,
displayURLs,
isDeleting: false,
};
}
case MEDIA_DELETE_FAILURE: {
return {
...state,
isDeleting: false,
};
}
case MEDIA_DISPLAY_URL_REQUEST:
return {
...state,
displayURLs: {
...state.displayURLs,
[action.payload.key]: {
...state.displayURLs[action.payload.key],
isFetching: true,
},
},
};
case MEDIA_DISPLAY_URL_SUCCESS: {
return {
...state,
displayURLs: {
...state.displayURLs,
[action.payload.key]: {
url: action.payload.url,
isFetching: false,
},
},
};
}
case MEDIA_DISPLAY_URL_FAILURE: {
const displayUrl = { ...state.displayURLs[action.payload.key] };
delete displayUrl.url;
displayUrl.isFetching = false;
displayUrl.err = action.payload.err ?? true;
return {
...state,
displayURLs: {
...state.displayURLs,
[action.payload.key]: displayUrl,
},
};
}
default:
return state;
}
}
export function selectMediaFiles(state: RootState, field?: Field): MediaFile[] {
const { mediaLibrary, entryDraft } = state;
const editingDraft = selectEditingDraft(entryDraft);
let files: MediaFile[] = [];
if (editingDraft) {
const entryFiles = entryDraft?.entry?.mediaFiles ?? [];
const entry = entryDraft['entry'];
const collection = entry?.collection ? state.collections[entry.collection] : null;
if (state.config.config) {
const mediaFolder = selectMediaFolder(state.config.config, collection, entry, field);
files = entryFiles
.filter(f => dirname(f.path) === mediaFolder)
.map(file => ({ key: file.id, ...file }));
}
} else {
files = mediaLibrary.files || [];
}
return files;
}
export function selectMediaFileByPath(state: RootState, path: string) {
const files = selectMediaFiles(state);
const file = files.find(file => file.path === path);
return file;
}
export function selectMediaDisplayURL(state: RootState, id: string) {
const displayUrlState = (get(state.mediaLibrary, ['displayURLs', id]) ?? {}) as DisplayURLState;
return displayUrlState;
}
export const selectMediaPath = (controlID: string) => (state: RootState) => {
return state.mediaLibrary.controlMedia[controlID];
};
export default mediaLibrary;

View File

@ -0,0 +1,66 @@
import { produce } from 'immer';
import {
ADD_ASSETS,
ADD_ASSET,
REMOVE_ASSET,
LOAD_ASSET_REQUEST,
LOAD_ASSET_SUCCESS,
LOAD_ASSET_FAILURE,
} from '../actions/media';
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 };
}
const defaultState: MediasState = {};
const medias = produce((state: MediasState, action: MediasAction) => {
switch (action.type) {
case ADD_ASSETS: {
const assets = action.payload;
assets.forEach(asset => {
state[asset.path] = { asset, isLoading: false, error: null };
});
break;
}
case ADD_ASSET: {
const asset = action.payload;
state[asset.path] = { asset, isLoading: false, error: null };
break;
}
case REMOVE_ASSET: {
const path = action.payload;
delete state[path];
break;
}
case LOAD_ASSET_REQUEST: {
const { path } = action.payload;
state[path] = state[path] || {};
state[path].isLoading = true;
break;
}
case LOAD_ASSET_SUCCESS: {
const { path } = action.payload;
state[path] = state[path] || {};
state[path].isLoading = false;
state[path].error = null;
break;
}
case LOAD_ASSET_FAILURE: {
const { path, error } = action.payload;
state[path] = state[path] || {};
state[path].isLoading = false;
state[path].error = error;
}
}
}, defaultState);
export function selectIsLoadingAsset(state: MediasState) {
return Object.values(state).some(state => state.isLoading);
}
export default medias;

View File

@ -0,0 +1,28 @@
import { produce } from 'immer';
import { SCROLL_SYNC_ENABLED, SET_SCROLL, TOGGLE_SCROLL } from '../actions/scroll';
import type { ScrollAction } from '../actions/scroll';
export interface ScrollState {
isScrolling: boolean;
}
const defaultState: ScrollState = {
isScrolling: true,
};
const status = produce((state: ScrollState, action: ScrollAction) => {
switch (action.type) {
case TOGGLE_SCROLL:
state.isScrolling = !state.isScrolling;
localStorage.setItem(SCROLL_SYNC_ENABLED, `${state.isScrolling}`);
break;
case SET_SCROLL:
state.isScrolling = action.payload;
localStorage.setItem(SCROLL_SYNC_ENABLED, `${state.isScrolling}`);
break;
}
}, defaultState);
export default status;

View File

@ -0,0 +1,88 @@
import {
QUERY_FAILURE,
QUERY_REQUEST,
QUERY_SUCCESS,
SEARCH_CLEAR,
SEARCH_ENTRIES_FAILURE,
SEARCH_ENTRIES_REQUEST,
SEARCH_ENTRIES_SUCCESS,
} from '../actions/search';
import type { SearchAction } from '../actions/search';
export interface SearchState {
isFetching: boolean;
term: string;
collections: string[];
page: number;
entryIds: { collection: string; slug: string }[];
error: Error | undefined;
}
const defaultState: SearchState = {
isFetching: false,
term: '',
collections: [],
page: 0,
entryIds: [],
error: undefined,
};
const search = (state: SearchState = defaultState, action: SearchAction): SearchState => {
switch (action.type) {
case SEARCH_CLEAR:
return defaultState;
case SEARCH_ENTRIES_REQUEST: {
const { page, searchTerm, searchCollections } = action.payload;
return {
...state,
isFetching: true,
term: searchTerm,
collections: searchCollections,
page,
};
}
case SEARCH_ENTRIES_SUCCESS: {
const { entries, page } = action.payload;
const entryIds = entries.map(entry => ({ collection: entry.collection, slug: entry.slug }));
return {
...state,
isFetching: false,
page,
entryIds: !page || isNaN(page) || page === 0 ? entryIds : state.entryIds.concat(entryIds),
};
}
case QUERY_FAILURE:
case SEARCH_ENTRIES_FAILURE: {
const { error } = action.payload;
return {
...state,
isFetching: false,
error,
};
}
case QUERY_REQUEST: {
const { searchTerm } = action.payload;
return {
...state,
isFetching: true,
term: searchTerm,
};
}
case QUERY_SUCCESS: {
return {
...state,
isFetching: false,
};
}
}
return state;
};
export default search;

View File

@ -0,0 +1,40 @@
import { produce } from 'immer';
import { STATUS_REQUEST, STATUS_SUCCESS, STATUS_FAILURE } from '../actions/status';
import type { StatusAction } from '../actions/status';
export interface StatusState {
isFetching: boolean;
status: {
auth: { status: boolean };
api: { status: boolean; statusPage: string };
};
error: Error | undefined;
}
const defaultState: StatusState = {
isFetching: false,
status: {
auth: { status: true },
api: { status: true, statusPage: '' },
},
error: undefined,
};
const status = produce((state: StatusState, action: StatusAction) => {
switch (action.type) {
case STATUS_REQUEST:
state.isFetching = true;
break;
case STATUS_SUCCESS:
state.isFetching = false;
state.status = action.payload.status;
break;
case STATUS_FAILURE:
state.isFetching = false;
state.error = action.payload.error;
}
}, defaultState);
export default status;