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,132 @@
import { currentBackend } from '../backend';
import { addSnackbar } from '../store/slices/snackbars';
import type { AnyAction } from 'redux';
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,
} as const;
}
export function authenticate(userData: User) {
return {
type: AUTH_SUCCESS,
payload: userData,
} as const;
}
export function authError(error: Error) {
return {
type: AUTH_FAILURE,
error: 'Failed to authenticate',
payload: error,
} as const;
}
export function doneAuthenticating() {
return {
type: AUTH_REQUEST_DONE,
} as const;
}
export function logout() {
return {
type: LOGOUT,
} as const;
}
// Check if user data token is cached and is valid
export function authenticateUser() {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
if (!state.config.config) {
return;
}
const backend = currentBackend(state.config.config);
dispatch(authenticating());
return Promise.resolve(backend.currentUser())
.then(user => {
if (user) {
dispatch(authenticate(user));
} else {
dispatch(doneAuthenticating());
}
})
.catch((error: unknown) => {
console.error(error);
if (error instanceof Error) {
dispatch(authError(error));
}
dispatch(logoutUser());
});
};
}
export function loginUser(credentials: Credentials) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
if (!state.config.config) {
return;
}
const backend = currentBackend(state.config.config);
dispatch(authenticating());
return backend
.authenticate(credentials)
.then(user => {
dispatch(authenticate(user));
})
.catch((error: unknown) => {
console.error(error);
if (error instanceof Error) {
dispatch(
addSnackbar({
type: 'warning',
message: {
key: 'ui.toast.onFailToAuth',
options: {
details: error.message,
},
},
}),
);
dispatch(authError(error));
}
});
};
}
export function logoutUser() {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
if (!state.config.config) {
return;
}
const backend = currentBackend(state.config.config);
Promise.resolve(backend.logout()).then(() => {
dispatch(logout());
});
};
}
export type AuthAction = ReturnType<
| typeof authenticating
| typeof authenticate
| typeof authError
| typeof doneAuthenticating
| typeof logout
>;

View File

@ -0,0 +1,18 @@
import { history } from '../routing/history';
import { getCollectionUrl, getNewEntryUrl } from '../lib/urlHelper';
export function searchCollections(query: string, collection?: string) {
if (collection) {
history.push(`/collections/${collection}/search/${query}`);
} else {
history.push(`/search/${query}`);
}
}
export function showCollection(collectionName: string) {
history.push(getCollectionUrl(collectionName));
}
export function createNewEntry(collectionName: string) {
history.push(getNewEntryUrl(collectionName));
}

View File

@ -0,0 +1,404 @@
import deepmerge from 'deepmerge';
import { produce } from 'immer';
import trim from 'lodash/trim';
import trimStart from 'lodash/trimStart';
import yaml from 'yaml';
import { resolveBackend } from '../backend';
import validateConfig from '../constants/configSchema';
import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n';
import { selectDefaultSortableFields } from '../lib/util/collection.util';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type {
BaseField,
Config,
Field,
I18nInfo,
ListField,
LocalBackend,
ObjectField,
UnknownField,
} 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);
}
function isFieldList<F extends BaseField = UnknownField>(field: Field<F>): field is ListField {
return 'types' in (field as ListField) || 'field' in (field as ListField);
}
function traverseFieldsJS<F extends Field>(
fields: F[],
updater: <T extends Field>(field: T) => T,
): F[] {
return fields.map(field => {
const newField = updater(field);
if (isObjectField(newField)) {
return { ...newField, fields: traverseFieldsJS(newField.fields, updater) };
} else if (isFieldList(newField) && newField.types) {
return { ...newField, types: traverseFieldsJS(newField.types, updater) };
}
return newField;
});
}
function getConfigUrl() {
const validTypes: { [type: string]: string } = {
'text/yaml': 'yaml',
'application/x-yaml': 'yaml',
};
const configLinkEl = document.querySelector<HTMLLinkElement>('link[rel="cms-config-url"]');
if (configLinkEl && validTypes[configLinkEl.type] && configLinkEl.href) {
console.info(`Using config file path: "${configLinkEl.href}"`);
return configLinkEl.href;
}
return 'config.yml';
}
function setDefaultPublicFolderForField<T extends Field>(field: T) {
if ('media_folder' in field && !('public_folder' in field)) {
return { ...field, public_folder: field.media_folder };
}
return field;
}
function setI18nField<T extends Field>(field: T) {
if (field[I18N] === true) {
return { ...field, [I18N]: I18N_FIELD.TRANSLATE };
} else if (field[I18N] === false || !field[I18N]) {
return { ...field, [I18N]: I18N_FIELD.NONE };
}
return field;
}
function getI18nDefaults(collectionOrFileI18n: boolean | I18nInfo, defaultI18n: I18nInfo) {
if (typeof collectionOrFileI18n === 'boolean') {
return defaultI18n;
} else {
const locales = collectionOrFileI18n.locales || defaultI18n.locales;
const defaultLocale = collectionOrFileI18n.defaultLocale || locales[0];
const mergedI18n: I18nInfo = deepmerge(defaultI18n, collectionOrFileI18n);
mergedI18n.locales = locales;
mergedI18n.defaultLocale = defaultLocale;
throwOnMissingDefaultLocale(mergedI18n);
return mergedI18n;
}
}
function setI18nDefaultsForFields(collectionOrFileFields: Field[], hasI18n: boolean) {
if (hasI18n) {
return traverseFieldsJS(collectionOrFileFields, setI18nField);
} else {
return traverseFieldsJS(collectionOrFileFields, field => {
const newField = { ...field };
delete newField[I18N];
return newField;
});
}
}
function throwOnInvalidFileCollectionStructure(i18n?: I18nInfo) {
if (i18n && i18n.structure !== I18N_STRUCTURE.SINGLE_FILE) {
throw new Error(
`i18n configuration for files collections is limited to ${I18N_STRUCTURE.SINGLE_FILE} structure`,
);
}
}
function throwOnMissingDefaultLocale(i18n?: I18nInfo) {
if (i18n && i18n.defaultLocale && !i18n.locales.includes(i18n.defaultLocale)) {
throw new Error(
`i18n locales '${i18n.locales.join(', ')}' are missing the default locale ${
i18n.defaultLocale
}`,
);
}
}
export function applyDefaults(originalConfig: Config) {
return produce(originalConfig, config => {
config.slug = config.slug || {};
config.collections = config.collections || [];
// Use `site_url` as default `display_url`.
if (!config.display_url && config.site_url) {
config.display_url = config.site_url;
}
// Use media_folder as default public_folder.
const defaultPublicFolder = `/${trimStart(config.media_folder, '/')}`;
if (!('public_folder' in config)) {
config.public_folder = defaultPublicFolder;
}
// default values for the slug config
if (!('encoding' in config.slug)) {
config.slug.encoding = 'unicode';
}
if (!('clean_accents' in config.slug)) {
config.slug.clean_accents = false;
}
if (!('sanitize_replacement' in config.slug)) {
config.slug.sanitize_replacement = '-';
}
const i18n = config[I18N];
if (i18n) {
i18n.defaultLocale = i18n.defaultLocale || i18n.locales[0];
}
throwOnMissingDefaultLocale(i18n);
const backend = resolveBackend(config);
for (const collection of config.collections) {
let collectionI18n = collection[I18N];
if (config.editor && !collection.editor) {
collection.editor = { preview: config.editor.preview, frame: config.editor.frame };
}
if (i18n && collectionI18n) {
collectionI18n = getI18nDefaults(collectionI18n, i18n);
collection[I18N] = collectionI18n;
} else {
collectionI18n = undefined;
delete collection[I18N];
}
if ('fields' in collection && collection.fields) {
collection.fields = setI18nDefaultsForFields(collection.fields, Boolean(collectionI18n));
}
const { view_filters, view_groups } = collection;
if ('folder' in collection && collection.folder) {
if (collection.path && !collection.media_folder) {
// default value for media folder when using the path config
collection.media_folder = '';
}
if ('media_folder' in collection && !('public_folder' in collection)) {
collection.public_folder = collection.media_folder;
}
if ('fields' in collection && collection.fields) {
collection.fields = traverseFieldsJS(collection.fields, setDefaultPublicFolderForField);
}
collection.folder = trim(collection.folder, '/');
}
if ('files' in collection && collection.files) {
throwOnInvalidFileCollectionStructure(collectionI18n);
for (const file of collection.files) {
file.file = trimStart(file.file, '/');
if ('media_folder' in file && !('public_folder' in file)) {
file.public_folder = file.media_folder;
}
if (file.fields) {
file.fields = traverseFieldsJS(file.fields, setDefaultPublicFolderForField);
}
let fileI18n = file[I18N];
if (fileI18n && collectionI18n) {
fileI18n = getI18nDefaults(fileI18n, collectionI18n);
file[I18N] = fileI18n;
} else {
fileI18n = undefined;
delete file[I18N];
}
throwOnInvalidFileCollectionStructure(fileI18n);
if (file.fields) {
file.fields = setI18nDefaultsForFields(file.fields, Boolean(fileI18n));
}
if (collection.editor && !file.editor) {
file.editor = { preview: collection.editor.preview, frame: collection.editor.frame };
}
}
}
if (!collection.sortable_fields) {
collection.sortable_fields = {
fields: selectDefaultSortableFields(collection, backend),
};
}
collection.view_filters = (view_filters || []).map(filter => {
return {
...filter,
id: `${filter.field}__${filter.pattern}`,
};
});
collection.view_groups = (view_groups || []).map(group => {
return {
...group,
id: `${group.field}__${group.pattern}`,
};
});
}
});
}
export function parseConfig(data: string) {
const config = yaml.parse(data, { maxAliasCount: -1, prettyErrors: true, merge: true });
if (
typeof window !== 'undefined' &&
typeof window.CMS_ENV === 'string' &&
config[window.CMS_ENV]
) {
const configKeys = Object.keys(config[window.CMS_ENV]) as ReadonlyArray<keyof Config>;
for (const key of configKeys) {
config[key] = config[window.CMS_ENV][key] as Config[keyof Config];
}
}
return config as Config;
}
async function getConfigYaml(file: string): Promise<Config> {
const response = await fetch(file, { credentials: 'same-origin' }).catch(error => error as Error);
if (response instanceof Error || response.status !== 200) {
const message = response instanceof Error ? response.message : response.status;
throw new Error(`Failed to load config.yml (${message})`);
}
const contentType = response.headers.get('Content-Type') ?? 'Not-Found';
const isYaml = contentType.indexOf('yaml') !== -1;
if (!isYaml) {
console.info(`Response for ${file} was not yaml. (Content-Type: ${contentType})`);
}
return parseConfig(await response.text());
}
export function configLoaded(config: Config) {
return {
type: CONFIG_SUCCESS,
payload: config,
} as const;
}
export function configLoading() {
return {
type: CONFIG_REQUEST,
} as const;
}
export function configFailed(err: Error) {
return {
type: CONFIG_FAILURE,
error: 'Error loading config',
payload: err,
} as const;
}
export async function detectProxyServer(localBackend?: boolean | LocalBackend) {
const allowedHosts = [
'localhost',
'127.0.0.1',
...(typeof localBackend === 'boolean' ? [] : localBackend?.allowed_hosts || []),
];
if (!allowedHosts.includes(location.hostname) || !localBackend) {
return {};
}
const defaultUrl = 'http://localhost:8081/api/v1';
const proxyUrl =
localBackend === true
? defaultUrl
: localBackend.url || defaultUrl.replace('localhost', location.hostname);
try {
console.info(`Looking for Static CMS Proxy Server at '${proxyUrl}'`);
const res = await fetch(`${proxyUrl}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'info' }),
});
const { repo, type } = (await res.json()) as {
repo?: string;
type?: string;
};
if (typeof repo === 'string' && typeof type === 'string') {
console.info(`Detected Static CMS Proxy Server at '${proxyUrl}' with repo: '${repo}'`);
return { proxyUrl, type };
} else {
console.info(`Static CMS Proxy Server not detected at '${proxyUrl}'`);
return {};
}
} catch {
console.info(`Static CMS Proxy Server not detected at '${proxyUrl}'`);
return {};
}
}
export async function handleLocalBackend(originalConfig: Config) {
if (!originalConfig.local_backend) {
return originalConfig;
}
const { proxyUrl } = await detectProxyServer(originalConfig.local_backend);
if (!proxyUrl) {
return originalConfig;
}
return produce(originalConfig, config => {
config.backend.name = 'proxy';
config.backend.proxy_url = proxyUrl;
});
}
export function loadConfig(manualConfig: Config | undefined, onLoad: (config: Config) => unknown) {
if (window.CMS_CONFIG) {
return configLoaded(window.CMS_CONFIG);
}
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>) => {
dispatch(configLoading());
try {
const configUrl = getConfigUrl();
const mergedConfig = manualConfig ? manualConfig : await getConfigYaml(configUrl);
validateConfig(mergedConfig);
const withLocalBackend = await handleLocalBackend(mergedConfig);
const config = applyDefaults(withLocalBackend);
dispatch(configLoaded(config));
if (typeof onLoad === 'function') {
onLoad(config);
}
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
dispatch(configFailed(error));
}
throw error;
}
};
}
export type ConfigAction = ReturnType<
typeof configLoading | typeof configLoaded | typeof configFailed
>;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,155 @@
import { isAbsolutePath } from '../lib/util';
import { selectMediaFilePath } from '../lib/util/media.util';
import { selectMediaFileByPath } from '../reducers/mediaLibrary';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import { getMediaDisplayURL, getMediaFile, waitForMediaLibraryToLoad } from './mediaLibrary';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { BaseField, Collection, Entry, Field, UnknownField } from '../interface';
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;
}
export function addAsset(assetProxy: AssetProxy) {
return { type: ADD_ASSET, payload: assetProxy } as const;
}
export function removeAsset(path: string) {
return { type: REMOVE_ASSET, payload: path } as const;
}
export function loadAssetRequest(path: string) {
return { type: LOAD_ASSET_REQUEST, payload: { path } } as const;
}
export function loadAssetSuccess(path: string) {
return { type: LOAD_ASSET_SUCCESS, payload: { path } } as const;
}
export function loadAssetFailure(path: string, error: Error) {
return { type: LOAD_ASSET_FAILURE, payload: { path, error } } as const;
}
export const emptyAsset = createAssetProxy({
path: 'empty.svg',
file: new File([`<svg xmlns="http://www.w3.org/2000/svg"></svg>`], 'empty.svg', {
type: 'image/svg+xml',
}),
});
async function loadAsset(
resolvedPath: string,
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
getState: () => RootState,
): Promise<AssetProxy> {
try {
dispatch(loadAssetRequest(resolvedPath));
// load asset url from backend
await waitForMediaLibraryToLoad(dispatch, getState());
const file = selectMediaFileByPath(getState(), resolvedPath);
let asset: AssetProxy;
if (file) {
const url = await getMediaDisplayURL(dispatch, getState(), file);
asset = createAssetProxy({ path: resolvedPath, url: url || resolvedPath });
dispatch(addAsset(asset));
} else {
const { url } = await getMediaFile(getState(), resolvedPath);
asset = createAssetProxy({ path: resolvedPath, url });
dispatch(addAsset(asset));
}
dispatch(loadAssetSuccess(resolvedPath));
return asset;
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
dispatch(loadAssetFailure(resolvedPath, error));
}
return emptyAsset;
}
}
const promiseCache: Record<string, Promise<AssetProxy>> = {};
export function getAsset<F extends BaseField = UnknownField>(
collection: Collection<F> | null | undefined,
entry: Entry | null | undefined,
path: string,
field?: F,
) {
return (
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
getState: () => RootState,
): Promise<AssetProxy> => {
if (!collection || !entry || !path) {
return Promise.resolve(emptyAsset);
}
const state = getState();
if (!state.config.config) {
return Promise.resolve(emptyAsset);
}
const resolvedPath = selectMediaFilePath(
state.config.config,
collection as Collection,
entry,
path,
field as Field,
);
let { asset, isLoading, error } = state.medias[resolvedPath] || {};
if (isLoading) {
return promiseCache[resolvedPath];
}
if (asset) {
// There is already an AssetProxy in memory for this path. Use it.
return Promise.resolve(asset);
}
const p = new Promise<AssetProxy>(resolve => {
if (isAbsolutePath(resolvedPath)) {
// asset path is a public url so we can just use it as is
asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset));
resolve(asset);
} else {
if (error) {
// on load error default back to original path
asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset));
resolve(asset);
} else {
loadAsset(resolvedPath, dispatch, getState).then(asset => {
resolve(asset);
});
}
}
});
promiseCache[resolvedPath] = p;
return p;
};
}
export type MediasAction = ReturnType<
| typeof addAssets
| typeof addAsset
| typeof removeAsset
| typeof loadAssetRequest
| typeof loadAssetSuccess
| typeof loadAssetFailure
>;

View File

@ -0,0 +1,559 @@
import { currentBackend } from '../backend';
import confirm from '../components/UI/Confirm';
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 { addSnackbar } from '../store/slices/snackbars';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import { addDraftEntryMediaFile, removeDraftEntryMediaFile } from './entries';
import { addAsset, removeAsset } from './media';
import { waitUntilWithTimeout } from './waitUntil';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type {
BaseField,
DisplayURLState,
Field,
ImplementationMediaFile,
MediaFile,
MediaLibraryInstance,
UnknownField,
} from '../interface';
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),
hide: instance.hide || (() => undefined),
onClearControl: instance.onClearControl || (() => undefined),
onRemoveControl: instance.onRemoveControl || (() => undefined),
enableStandalone: instance.enableStandalone || (() => undefined),
};
return { type: MEDIA_LIBRARY_CREATE, payload: api } as const;
}
export function clearMediaControl(id: string) {
return (_dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.externalLibrary;
if (mediaLibrary) {
mediaLibrary.onClearControl?.({ id });
}
};
}
export function removeMediaControl(id: string) {
return (_dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.externalLibrary;
if (mediaLibrary) {
mediaLibrary.onRemoveControl?.({ id });
}
};
}
export function openMediaLibrary<F extends BaseField = UnknownField>(
payload: {
controlID?: string;
forImage?: boolean;
value?: string | string[];
allowMultiple?: boolean;
replaceIndex?: number;
config?: Record<string, unknown>;
field?: F;
} = {},
) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.externalLibrary;
const { controlID, value, config = {}, allowMultiple, forImage, replaceIndex, field } = payload;
if (mediaLibrary) {
mediaLibrary.show({ id: controlID, value, config, allowMultiple, imagesOnly: forImage });
}
dispatch(
mediaLibraryOpened({
controlID,
forImage,
value,
allowMultiple,
replaceIndex,
config,
field: field as Field,
}),
);
};
}
export function closeMediaLibrary() {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.externalLibrary;
if (mediaLibrary) {
mediaLibrary.hide?.();
}
dispatch(mediaLibraryClosed());
};
}
export function insertMedia(mediaPath: string | string[], field: Field | undefined) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const config = state.config.config;
const entry = state.entryDraft.entry;
const collectionName = state.entryDraft.entry?.collection;
if (!collectionName || !config) {
return;
}
const collection = state.collections[collectionName];
if (Array.isArray(mediaPath)) {
mediaPath = mediaPath.map(path =>
selectMediaFilePublicPath(config, collection, path, entry, field),
);
} else {
mediaPath = selectMediaFilePublicPath(config, collection, mediaPath as string, entry, field);
}
dispatch(mediaInserted(mediaPath));
};
}
export function removeInsertedMedia(controlID: string) {
return { type: MEDIA_REMOVE_INSERTED, payload: { controlID } } as const;
}
export function loadMedia(opts: { delay?: number; query?: string; page?: number } = {}) {
const { delay = 0, page = 1 } = opts;
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const config = state.config.config;
if (!config) {
return;
}
const backend = currentBackend(config);
dispatch(mediaLoading(page));
function loadFunction() {
return backend
.getMedia()
.then(files => dispatch(mediaLoaded(files)))
.catch((error: { status?: number }) => {
console.error(error);
if (error.status === 404) {
console.info('This 404 was expected and handled appropriately.');
dispatch(mediaLoaded([]));
} else {
dispatch(mediaLoadFailed());
}
});
}
if (delay > 0) {
return new Promise(resolve => {
setTimeout(() => resolve(loadFunction()), delay);
});
} else {
return loadFunction();
}
};
}
function createMediaFileFromAsset({
id,
file,
assetProxy,
draft,
}: {
id: string;
file: File;
assetProxy: AssetProxy;
draft: boolean;
}): ImplementationMediaFile {
const mediaFile = {
id,
name: basename(assetProxy.path),
displayURL: assetProxy.url,
draft,
file,
size: file.size,
url: assetProxy.url,
path: assetProxy.path,
field: assetProxy.field,
};
return mediaFile;
}
export function persistMedia(file: File, opts: MediaOptions = {}) {
const { field } = opts;
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const config = state.config.config;
if (!config) {
return;
}
const backend = currentBackend(config);
const files: MediaFile[] = selectMediaFiles(state, field);
const fileName = sanitizeSlug(file.name.toLowerCase(), config.slug);
const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName);
const editingDraft = selectEditingDraft(state.entryDraft);
/**
* Check for existing files of the same name before persisting. If no asset
* store integration is used, files are being stored in Git, so we can
* expect file names to be unique. If an asset store is in use, file names
* may not be unique, so we forego this check.
*/
if (existingFile) {
if (
!(await confirm({
title: 'mediaLibrary.mediaLibrary.alreadyExistsTitle',
body: {
key: 'mediaLibrary.mediaLibrary.alreadyExistsBody',
options: { filename: existingFile.name },
},
color: 'error',
}))
) {
return;
} else {
await dispatch(deleteMedia(existingFile));
}
}
if (!editingDraft) {
dispatch(mediaPersisting());
}
try {
const entry = state.entryDraft.entry;
const collection = entry?.collection ? state.collections[entry.collection] : null;
const path = selectMediaFilePath(config, collection, entry, fileName, field);
const assetProxy = createAssetProxy({
file,
path,
field,
});
dispatch(addAsset(assetProxy));
let mediaFile: ImplementationMediaFile;
if (editingDraft) {
const id = await getBlobSHA(file);
mediaFile = createMediaFileFromAsset({
id,
file,
assetProxy,
draft: Boolean(editingDraft),
});
return dispatch(addDraftEntryMediaFile(mediaFile));
} else {
mediaFile = await backend.persistMedia(config, assetProxy);
}
return dispatch(mediaPersisted(mediaFile));
} catch (error) {
console.error(error);
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onFailToPersistMedia',
options: {
details: error,
},
},
}),
);
return dispatch(mediaPersistFailed());
}
};
}
export function deleteMedia(file: MediaFile) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const config = state.config.config;
if (!config) {
return;
}
const backend = currentBackend(config);
try {
if (file.draft) {
dispatch(removeAsset(file.path));
dispatch(removeDraftEntryMediaFile({ id: file.id }));
} else {
const editingDraft = selectEditingDraft(state.entryDraft);
dispatch(mediaDeleting());
dispatch(removeAsset(file.path));
await backend.deleteMedia(config, file.path);
dispatch(mediaDeleted(file));
if (editingDraft) {
dispatch(removeDraftEntryMediaFile({ id: file.id }));
}
}
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onFailToDeleteMedia',
options: {
details: error.message,
},
},
}),
);
}
return dispatch(mediaDeleteFailed());
}
};
}
export async function getMediaFile(state: RootState, path: string) {
const config = state.config.config;
if (!config) {
return { url: '' };
}
const backend = currentBackend(config);
const { url } = await backend.getMediaFile(path);
return { url };
}
export function loadMediaDisplayURL(file: MediaFile) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const { displayURL, id } = file;
const state = getState();
const config = state.config.config;
if (!config) {
return Promise.reject();
}
const displayURLState: DisplayURLState = selectMediaDisplayURL(state, id);
if (
!id ||
!displayURL ||
displayURLState.url ||
displayURLState.isFetching ||
displayURLState.err
) {
return Promise.resolve();
}
if (typeof displayURL === 'string') {
dispatch(mediaDisplayURLRequest(id));
dispatch(mediaDisplayURLSuccess(id, displayURL));
return;
}
try {
const backend = currentBackend(config);
dispatch(mediaDisplayURLRequest(id));
const newURL = await backend.getMediaDisplayURL(displayURL);
if (newURL) {
dispatch(mediaDisplayURLSuccess(id, newURL));
} else {
throw new Error('No display URL was returned!');
}
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
dispatch(mediaDisplayURLFailure(id, error));
}
}
};
}
function mediaLibraryOpened(payload: {
controlID?: string;
forImage?: boolean;
value?: string | string[];
replaceIndex?: number;
allowMultiple?: boolean;
config?: Record<string, unknown>;
field?: Field;
}) {
return { type: MEDIA_LIBRARY_OPEN, payload } as const;
}
function mediaLibraryClosed() {
return { type: MEDIA_LIBRARY_CLOSE } as const;
}
function mediaInserted(mediaPath: string | string[]) {
return { type: MEDIA_INSERT, payload: { mediaPath } } as const;
}
export function mediaLoading(page: number) {
return {
type: MEDIA_LOAD_REQUEST,
payload: { page },
} as const;
}
export interface MediaOptions {
field?: Field;
page?: number;
canPaginate?: boolean;
dynamicSearch?: boolean;
dynamicSearchQuery?: string;
}
export function mediaLoaded(files: ImplementationMediaFile[], opts: MediaOptions = {}) {
return {
type: MEDIA_LOAD_SUCCESS,
payload: { files, ...opts },
} as const;
}
export function mediaLoadFailed() {
return { type: MEDIA_LOAD_FAILURE } as const;
}
export function mediaPersisting() {
return { type: MEDIA_PERSIST_REQUEST } as const;
}
export function mediaPersisted(file: ImplementationMediaFile) {
return {
type: MEDIA_PERSIST_SUCCESS,
payload: { file },
} as const;
}
export function mediaPersistFailed() {
return { type: MEDIA_PERSIST_FAILURE } as const;
}
export function mediaDeleting() {
return { type: MEDIA_DELETE_REQUEST } as const;
}
export function mediaDeleted(file: MediaFile) {
return {
type: MEDIA_DELETE_SUCCESS,
payload: { file },
} as const;
}
export function mediaDeleteFailed() {
return { type: MEDIA_DELETE_FAILURE } as const;
}
export function mediaDisplayURLRequest(key: string) {
return { type: MEDIA_DISPLAY_URL_REQUEST, payload: { key } } as const;
}
export function mediaDisplayURLSuccess(key: string, url: string) {
return {
type: MEDIA_DISPLAY_URL_SUCCESS,
payload: { key, url },
} as const;
}
export function mediaDisplayURLFailure(key: string, err: Error) {
return {
type: MEDIA_DISPLAY_URL_FAILURE,
payload: { key, err },
} as const;
}
export async function waitForMediaLibraryToLoad(
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
state: RootState,
) {
if (state.mediaLibrary.isLoading !== false && !state.mediaLibrary.externalLibrary) {
await waitUntilWithTimeout(dispatch, resolve => ({
predicate: ({ type }) => type === MEDIA_LOAD_SUCCESS || type === MEDIA_LOAD_FAILURE,
run: () => resolve(),
}));
}
}
export async function getMediaDisplayURL(
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
state: RootState,
file: MediaFile,
) {
const displayURLState: DisplayURLState = selectMediaDisplayURL(state, file.id);
let url: string | null | undefined;
if (displayURLState.url) {
// url was already loaded
url = displayURLState.url;
} else if (displayURLState.err) {
// url loading had an error
url = null;
} else {
const key = file.id;
const promise = waitUntilWithTimeout<string>(dispatch, resolve => ({
predicate: ({ type, payload }) =>
(type === MEDIA_DISPLAY_URL_SUCCESS || type === MEDIA_DISPLAY_URL_FAILURE) &&
payload.key === key,
run: (_dispatch, _getState, action) => resolve(action.payload.url),
}));
if (!displayURLState.isFetching) {
// load display url
dispatch(loadMediaDisplayURL(file));
}
url = await promise;
}
return url;
}
export type MediaLibraryAction = ReturnType<
| typeof createMediaLibrary
| typeof mediaLibraryOpened
| typeof mediaLibraryClosed
| typeof mediaInserted
| typeof removeInsertedMedia
| typeof mediaLoading
| typeof mediaLoaded
| typeof mediaLoadFailed
| typeof mediaPersisting
| typeof mediaPersisted
| typeof mediaPersistFailed
| typeof mediaDeleting
| typeof mediaDeleted
| typeof mediaDeleteFailed
| typeof mediaDisplayURLRequest
| typeof mediaDisplayURLSuccess
| typeof mediaDisplayURLFailure
>;

View File

@ -0,0 +1,32 @@
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,
} as const;
}
export function loadScroll() {
return {
type: SET_SCROLL,
payload: localStorage.getItem(SCROLL_SYNC_ENABLED) !== 'false',
} as const;
}
export function toggleScroll() {
return async (
dispatch: ThunkDispatch<RootState, undefined, AnyAction>,
_getState: () => RootState,
) => {
return dispatch(togglingScroll());
};
}
export type ScrollAction = ReturnType<typeof togglingScroll | typeof loadScroll>;

View File

@ -0,0 +1,187 @@
import isEqual from 'lodash/isEqual';
import { currentBackend } from '../backend';
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
*/
export function searchingEntries(searchTerm: string, searchCollections: string[], page: number) {
return {
type: SEARCH_ENTRIES_REQUEST,
payload: { searchTerm, searchCollections, page },
} as const;
}
export function searchSuccess(entries: Entry[], page: number) {
return {
type: SEARCH_ENTRIES_SUCCESS,
payload: {
entries,
page,
},
} as const;
}
export function searchFailure(error: Error) {
return {
type: SEARCH_ENTRIES_FAILURE,
payload: { error },
} as const;
}
export function querying(searchTerm: string) {
return {
type: QUERY_REQUEST,
payload: {
searchTerm,
},
} as const;
}
export function querySuccess(namespace: string, hits: Entry[]) {
return {
type: QUERY_SUCCESS,
payload: {
namespace,
hits,
},
} as const;
}
export function queryFailure(error: Error) {
return {
type: QUERY_FAILURE,
payload: { error },
} as const;
}
/*
* Exported simple Action Creators
*/
export function clearSearch() {
return { type: SEARCH_CLEAR } as const;
}
/*
* Exported Thunk Action Creators
*/
// SearchEntries will search for complete entries in all collections.
export function searchEntries(searchTerm: string, searchCollections: string[], page = 0) {
return async (
dispatch: ThunkDispatch<RootState, undefined, AnyAction>,
getState: () => RootState,
) => {
const state = getState();
const { search } = state;
const configState = state.config;
if (!configState.config) {
return;
}
const backend = currentBackend(configState.config);
const allCollections = searchCollections || Object.keys(state.collections);
// avoid duplicate searches
if (
search.isFetching &&
search.term === searchTerm &&
isEqual(allCollections, search.collections)
) {
return;
}
dispatch(searchingEntries(searchTerm, allCollections, page));
try {
const response = await backend.search(
Object.entries(state.collections)
.filter(([key, _value]) => allCollections.indexOf(key) !== -1)
.map(([_key, value]) => value),
searchTerm,
);
return dispatch(searchSuccess(response.entries, page));
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
return dispatch(searchFailure(error));
}
}
};
}
// Instead of searching for complete entries, query will search for specific fields
// in specific collections and return raw data (no entries).
export function query(
namespace: string,
collectionName: string,
searchFields: string[],
searchTerm: string,
file?: string,
limit?: number,
) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
dispatch(querying(searchTerm));
const state = getState();
const configState = state.config;
if (!configState.config) {
return dispatch(queryFailure(new Error('Config not found')));
}
const backend = currentBackend(configState.config);
const collection = Object.values(state.collections).find(
collection => collection.name === collectionName,
);
if (!collection) {
return dispatch(queryFailure(new Error('Collection not found')));
}
try {
const response: SearchQueryResponse = await backend.query(
collection,
searchFields,
searchTerm,
file,
limit,
);
return dispatch(querySuccess(namespace, response.hits));
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
return dispatch(queryFailure(error));
}
}
};
}
export type SearchAction = ReturnType<
| typeof searchingEntries
| typeof searchSuccess
| typeof searchFailure
| typeof querying
| typeof querySuccess
| typeof queryFailure
| typeof clearSearch
>;

View File

@ -0,0 +1,101 @@
import { currentBackend } from '../backend';
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,
} as const;
}
export function statusSuccess(status: {
auth: { status: boolean };
api: { status: boolean; statusPage: string };
}) {
return {
type: STATUS_SUCCESS,
payload: { status },
} as const;
}
export function statusFailure(error: Error) {
return {
type: STATUS_FAILURE,
payload: { error },
} as const;
}
export function checkBackendStatus() {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
try {
const state = getState();
const config = state.config.config;
if (state.status.isFetching || !config) {
return;
}
dispatch(statusRequest());
const backend = currentBackend(config);
const status = await backend.status();
const backendDownKey = 'ui.toast.onBackendDown';
const previousBackendDownNotifs = state.snackbar.messages.filter(
n => typeof n.message !== 'string' && n.message.key === backendDownKey,
);
if (status.api.status === false) {
if (previousBackendDownNotifs.length === 0) {
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onBackendDown',
options: { details: status.api.statusPage },
},
}),
);
}
return dispatch(statusSuccess(status));
} else if (status.api.status === true && previousBackendDownNotifs.length > 0) {
// If backend is up, clear all the danger messages
previousBackendDownNotifs.forEach(notif => {
dispatch(removeSnackbarById(notif.id));
});
}
const authError = status.auth.status === false;
if (authError) {
const key = 'ui.toast.onLoggedOut';
const existingNotification = state.snackbar.messages.find(
n => typeof n.message !== 'string' && n.message.key === key,
);
if (!existingNotification) {
dispatch(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.onLoggedOut' },
}),
);
}
}
dispatch(statusSuccess(status));
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
dispatch(statusFailure(error));
}
}
};
}
export type StatusAction = ReturnType<
typeof statusRequest | typeof statusSuccess | typeof statusFailure
>;

View File

@ -0,0 +1,49 @@
import { WAIT_UNTIL_ACTION } from '../store/middleware/waitUntilAction';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { RootState } from '../store';
import type { WaitActionArgs } from '../store/middleware/waitUntilAction';
export function waitUntil({ predicate, run }: WaitActionArgs) {
return {
type: WAIT_UNTIL_ACTION,
predicate,
run,
};
}
export async function waitUntilWithTimeout<T>(
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
waitActionArgs: (resolve: (value?: T) => void) => WaitActionArgs,
timeout = 30000,
): Promise<T | null | undefined> {
let waitDone = false;
const waitPromise = new Promise<T | undefined>(resolve => {
dispatch(waitUntil(waitActionArgs(resolve)));
});
const timeoutPromise = new Promise<T | null>(resolve => {
setTimeout(() => {
if (waitDone) {
resolve(null);
} else {
console.warn('Wait Action timed out');
resolve(null);
}
}, timeout);
});
const result = await Promise.race([
waitPromise
.then(result => {
waitDone = true;
return result;
})
.catch(null),
timeoutPromise,
]);
return result;
}