planning
All checks were successful
Publish To Prod / deploy_and_publish (push) Successful in 35s

This commit is contained in:
2024-10-14 09:15:30 +02:00
parent bcba00a730
commit 6e64e138e2
21059 changed files with 2317811 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,216 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fromJS } from 'immutable';
import { addAssets } from '../media';
import * as actions from '../editorialWorkflow';
jest.mock('../../backend');
jest.mock('../../valueObjects/AssetProxy');
jest.mock('decap-cms-lib-util');
jest.mock('uuid', () => {
return { v4: jest.fn().mockReturnValue('000000000000000000000') };
});
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('editorialWorkflow actions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('loadUnpublishedEntry', () => {
it('should load unpublished entry', () => {
const { currentBackend } = require('../../backend');
const { createAssetProxy } = require('../../valueObjects/AssetProxy');
const assetProxy = { name: 'name', path: 'path' };
const entry = { mediaFiles: [{ file: { name: 'name' }, id: '1', draft: true }] };
const backend = {
unpublishedEntry: jest.fn().mockResolvedValue(entry),
};
const store = mockStore({
config: fromJS({}),
collections: fromJS({
posts: { name: 'posts' },
}),
mediaLibrary: fromJS({
isLoading: false,
}),
editorialWorkflow: fromJS({
pages: { ids: [] },
}),
});
currentBackend.mockReturnValue(backend);
createAssetProxy.mockResolvedValue(assetProxy);
const slug = 'slug';
const collection = store.getState().collections.get('posts');
return store.dispatch(actions.loadUnpublishedEntry(collection, slug)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(4);
expect(actions[0]).toEqual({
type: 'UNPUBLISHED_ENTRY_REQUEST',
payload: {
collection: 'posts',
slug,
},
});
expect(actions[1]).toEqual(addAssets([assetProxy]));
expect(actions[2]).toEqual({
type: 'UNPUBLISHED_ENTRY_SUCCESS',
payload: {
collection: 'posts',
entry: { ...entry, mediaFiles: [{ file: { name: 'name' }, id: '1', draft: true }] },
},
});
expect(actions[3]).toEqual({
type: 'DRAFT_CREATE_FROM_ENTRY',
payload: {
entry,
},
});
});
});
});
describe('publishUnpublishedEntry', () => {
it('should publish unpublished entry and report success', () => {
const { currentBackend } = require('../../backend');
const entry = {};
const backend = {
publishUnpublishedEntry: jest.fn().mockResolvedValue(),
getEntry: jest.fn().mockResolvedValue(entry),
getMedia: jest.fn().mockResolvedValue([]),
};
const store = mockStore({
config: fromJS({}),
integrations: fromJS([]),
mediaLibrary: fromJS({
isLoading: false,
}),
collections: fromJS({
posts: { name: 'posts' },
}),
});
currentBackend.mockReturnValue(backend);
const slug = 'slug';
return store.dispatch(actions.publishUnpublishedEntry('posts', slug)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(8);
expect(actions[0]).toEqual({
type: 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST',
payload: {
collection: 'posts',
slug,
},
});
expect(actions[1]).toEqual({
type: 'MEDIA_LOAD_REQUEST',
payload: {
page: 1,
},
});
expect(actions[2]).toEqual({
type: 'NOTIFICATION_SEND',
payload: {
message: { key: 'ui.toast.entryPublished' },
type: 'success',
dismissAfter: 4000,
},
});
expect(actions[3]).toEqual({
type: 'UNPUBLISHED_ENTRY_PUBLISH_SUCCESS',
payload: {
collection: 'posts',
slug,
},
});
expect(actions[4]).toEqual({
type: 'MEDIA_LOAD_SUCCESS',
payload: {
files: [],
},
});
expect(actions[5]).toEqual({
type: 'ENTRY_REQUEST',
payload: {
slug,
collection: 'posts',
},
});
expect(actions[6]).toEqual({
type: 'ENTRY_SUCCESS',
payload: {
entry,
collection: 'posts',
},
});
expect(actions[7]).toEqual({
type: 'DRAFT_CREATE_FROM_ENTRY',
payload: {
entry,
},
});
});
});
it('should publish unpublished entry and report error', () => {
const { currentBackend } = require('../../backend');
const error = new Error('failed to publish entry');
const backend = {
publishUnpublishedEntry: jest.fn().mockRejectedValue(error),
};
const store = mockStore({
config: fromJS({}),
collections: fromJS({
posts: { name: 'posts' },
}),
});
currentBackend.mockReturnValue(backend);
const slug = 'slug';
return store.dispatch(actions.publishUnpublishedEntry('posts', slug)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(3);
expect(actions[0]).toEqual({
type: 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST',
payload: {
collection: 'posts',
slug,
},
});
expect(actions[1]).toEqual({
type: 'NOTIFICATION_SEND',
payload: {
message: { key: 'ui.toast.onFailToPublishEntry', details: error },
type: 'error',
dismissAfter: 8000,
},
});
expect(actions[2]).toEqual({
type: 'UNPUBLISHED_ENTRY_PUBLISH_FAILURE',
payload: {
collection: 'posts',
slug,
},
});
});
});
});
});

View File

@@ -0,0 +1,575 @@
import { fromJS, Map } from 'immutable';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {
createEmptyDraft,
createEmptyDraftData,
retrieveLocalBackup,
persistLocalBackup,
getMediaAssets,
validateMetaField,
} from '../entries';
import AssetProxy from '../../valueObjects/AssetProxy';
jest.mock('../../backend');
jest.mock('decap-cms-lib-util');
jest.mock('../mediaLibrary');
jest.mock('../../reducers/entries');
jest.mock('../../reducers/entryDraft');
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('entries', () => {
describe('createEmptyDraft', () => {
const { currentBackend } = require('../../backend');
const backend = {
processEntry: jest.fn((_state, _collection, entry) => Promise.resolve(entry)),
};
currentBackend.mockReturnValue(backend);
beforeEach(() => {
jest.clearAllMocks();
});
it('should dispatch draft created action', () => {
const store = mockStore({ mediaLibrary: fromJS({ files: [] }) });
const collection = fromJS({
fields: [{ name: 'title' }],
});
return store.dispatch(createEmptyDraft(collection, '')).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
payload: {
author: '',
collection: undefined,
data: {},
meta: {},
i18n: {},
isModification: null,
label: null,
mediaFiles: [],
partial: false,
path: '',
raw: '',
slug: '',
status: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
});
});
});
it('should populate draft entry from URL param', () => {
const store = mockStore({ mediaLibrary: fromJS({ files: [] }) });
const collection = fromJS({
fields: [{ name: 'title' }, { name: 'boolean' }],
});
return store.dispatch(createEmptyDraft(collection, '?title=title&boolean=True')).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
payload: {
author: '',
collection: undefined,
data: { title: 'title', boolean: true },
meta: {},
i18n: {},
isModification: null,
label: null,
mediaFiles: [],
partial: false,
path: '',
raw: '',
slug: '',
status: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
});
});
});
it('should html escape URL params', () => {
const store = mockStore({ mediaLibrary: fromJS({ files: [] }) });
const collection = fromJS({
fields: [{ name: 'title' }],
});
return store
.dispatch(createEmptyDraft(collection, "?title=<script>alert('hello')</script>"))
.then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
payload: {
author: '',
collection: undefined,
data: { title: '&lt;script&gt;alert(&#039;hello&#039;)&lt;/script&gt;' },
meta: {},
i18n: {},
isModification: null,
label: null,
mediaFiles: [],
partial: false,
path: '',
raw: '',
slug: '',
status: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
});
});
});
});
describe('createEmptyDraftData', () => {
it('should allow an empty array as list default for a single field list', () => {
const fields = fromJS([
{
name: 'images',
widget: 'list',
default: [],
field: { name: 'url', widget: 'text' },
},
]);
expect(createEmptyDraftData(fields)).toEqual({ images: fromJS([]) });
});
it('should allow a complex array as list default for a single field list', () => {
const fields = fromJS([
{
name: 'images',
widget: 'list',
default: [
{
url: 'https://image.png',
},
],
field: { name: 'url', widget: 'text' },
},
]);
expect(createEmptyDraftData(fields)).toEqual({
images: fromJS([
{
url: 'https://image.png',
},
]),
});
});
it('should allow an empty array as list default for a fields list', () => {
const fields = fromJS([
{
name: 'images',
widget: 'list',
default: [],
fields: [
{ name: 'title', widget: 'text' },
{ name: 'url', widget: 'text' },
],
},
]);
expect(createEmptyDraftData(fields)).toEqual({ images: fromJS([]) });
});
it('should allow a complex array as list default for a fields list', () => {
const fields = fromJS([
{
name: 'images',
widget: 'list',
default: [
{
title: 'default image',
url: 'https://image.png',
},
],
fields: [
{ name: 'title', widget: 'text' },
{ name: 'url', widget: 'text' },
],
},
]);
expect(createEmptyDraftData(fields)).toEqual({
images: fromJS([
{
title: 'default image',
url: 'https://image.png',
},
]),
});
});
it('should use field default when no list default is provided', () => {
const fields = fromJS([
{
name: 'images',
widget: 'list',
field: { name: 'url', widget: 'text', default: 'https://image.png' },
},
]);
expect(createEmptyDraftData(fields)).toEqual({ images: [{ url: 'https://image.png' }] });
});
it('should use fields default when no list default is provided', () => {
const fields = fromJS([
{
name: 'images',
widget: 'list',
fields: [
{ name: 'title', widget: 'text', default: 'default image' },
{ name: 'url', widget: 'text', default: 'https://image.png' },
],
},
]);
expect(createEmptyDraftData(fields)).toEqual({
images: [{ title: 'default image', url: 'https://image.png' }],
});
});
it('should not set empty value for list fields widget', () => {
const fields = fromJS([
{
name: 'images',
widget: 'list',
fields: [
{ name: 'title', widget: 'text' },
{ name: 'url', widget: 'text' },
],
},
]);
expect(createEmptyDraftData(fields)).toEqual({});
});
it('should set default value for object field widget', () => {
const fields = fromJS([
{
name: 'post',
widget: 'object',
field: { name: 'image', widget: 'text', default: 'https://image.png' },
},
]);
expect(createEmptyDraftData(fields)).toEqual({ post: { image: 'https://image.png' } });
});
it('should set default values for object fields widget', () => {
const fields = fromJS([
{
name: 'post',
widget: 'object',
fields: [
{ name: 'title', widget: 'text', default: 'default title' },
{ name: 'url', widget: 'text', default: 'https://image.png' },
],
},
]);
expect(createEmptyDraftData(fields)).toEqual({
post: { title: 'default title', url: 'https://image.png' },
});
});
it('should not set empty value for object fields widget', () => {
const fields = fromJS([
{
name: 'post',
widget: 'object',
fields: [
{ name: 'title', widget: 'text' },
{ name: 'url', widget: 'text' },
],
},
]);
expect(createEmptyDraftData(fields)).toEqual({});
});
it('should populate nested fields', () => {
const fields = fromJS([
{
name: 'names',
widget: 'list',
field: {
name: 'object',
widget: 'object',
fields: [
{ name: 'first', widget: 'string', default: 'first' },
{ name: 'second', widget: 'string', default: 'second' },
],
},
},
]);
expect(createEmptyDraftData(fields)).toEqual({
names: [{ object: { first: 'first', second: 'second' } }],
});
});
});
describe('persistLocalBackup', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should persist local backup with media files', () => {
const { currentBackend } = require('../../backend');
const backend = {
persistLocalDraftBackup: jest.fn(() => Promise.resolve()),
};
const store = mockStore({
config: Map(),
});
currentBackend.mockReturnValue(backend);
const collection = Map();
const mediaFiles = [{ path: 'static/media/image.png' }];
const entry = fromJS({ mediaFiles });
return store.dispatch(persistLocalBackup(entry, collection)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(0);
expect(backend.persistLocalDraftBackup).toHaveBeenCalledTimes(1);
expect(backend.persistLocalDraftBackup).toHaveBeenCalledWith(entry, collection);
});
});
});
describe('retrieveLocalBackup', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should retrieve media files with local backup', () => {
const { currentBackend } = require('../../backend');
const { createAssetProxy } = require('../../valueObjects/AssetProxy');
const backend = {
getLocalDraftBackup: jest.fn((...args) => args),
};
const store = mockStore({
config: Map(),
});
currentBackend.mockReturnValue(backend);
const collection = Map({
name: 'collection',
});
const slug = 'slug';
const file = new File([], 'image.png');
const mediaFiles = [{ path: 'static/media/image.png', url: 'url', file }];
const asset = createAssetProxy(mediaFiles[0]);
const entry = { mediaFiles };
backend.getLocalDraftBackup.mockReturnValue({ entry });
return store.dispatch(retrieveLocalBackup(collection, slug)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
type: 'ADD_ASSETS',
payload: [asset],
});
expect(actions[1]).toEqual({
type: 'DRAFT_LOCAL_BACKUP_RETRIEVED',
payload: { entry },
});
});
});
});
describe('getMediaAssets', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should map mediaFiles to assets', () => {
const mediaFiles = fromJS([{ path: 'path1' }, { path: 'path2', draft: true }]);
const entry = Map({ mediaFiles });
expect(getMediaAssets({ entry })).toEqual([new AssetProxy({ path: 'path2' })]);
});
});
describe('validateMetaField', () => {
const state = {
config: {
slug: {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
},
},
entries: fromJS([]),
};
const collection = fromJS({
folder: 'folder',
type: 'folder_based_collection',
name: 'name',
});
const t = jest.fn((key, args) => ({ key, args }));
const { selectCustomPath } = require('../../reducers/entryDraft');
const { selectEntryByPath } = require('../../reducers/entries');
beforeEach(() => {
jest.clearAllMocks();
});
it('should not return error on non meta field', () => {
expect(validateMetaField(null, null, fromJS({}), null, t)).toEqual({ error: false });
});
it('should not return error on meta path field', () => {
expect(validateMetaField(null, null, fromJS({ meta: true, name: 'other' }), null, t)).toEqual(
{ error: false },
);
});
it('should return error on empty path', () => {
expect(validateMetaField(null, null, fromJS({ meta: true, name: 'path' }), null, t)).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: null },
},
type: 'CUSTOM',
},
});
expect(
validateMetaField(null, null, fromJS({ meta: true, name: 'path' }), undefined, t),
).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: undefined },
},
type: 'CUSTOM',
},
});
expect(validateMetaField(null, null, fromJS({ meta: true, name: 'path' }), '', t)).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: '' },
},
type: 'CUSTOM',
},
});
});
it('should return error on invalid path', () => {
expect(
validateMetaField(state, null, fromJS({ meta: true, name: 'path' }), 'invalid path', t),
).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: 'invalid path' },
},
type: 'CUSTOM',
},
});
});
it('should return error on existing path', () => {
selectCustomPath.mockReturnValue('existing-path');
selectEntryByPath.mockReturnValue(fromJS({ path: 'existing-path' }));
expect(
validateMetaField(
{
...state,
entryDraft: fromJS({
entry: {},
}),
},
collection,
fromJS({ meta: true, name: 'path' }),
'existing-path',
t,
),
).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.pathExists',
args: { path: 'existing-path' },
},
type: 'CUSTOM',
},
});
expect(selectCustomPath).toHaveBeenCalledTimes(1);
expect(selectCustomPath).toHaveBeenCalledWith(
collection,
fromJS({ entry: { meta: { path: 'existing-path' } } }),
);
expect(selectEntryByPath).toHaveBeenCalledTimes(1);
expect(selectEntryByPath).toHaveBeenCalledWith(
state.entries,
collection.get('name'),
'existing-path',
);
});
it('should not return error on non existing path for new entry', () => {
selectCustomPath.mockReturnValue('non-existing-path');
selectEntryByPath.mockReturnValue(undefined);
expect(
validateMetaField(
{
...state,
entryDraft: fromJS({
entry: {},
}),
},
collection,
fromJS({ meta: true, name: 'path' }),
'non-existing-path',
t,
),
).toEqual({
error: false,
});
});
it('should not return error when for existing entry', () => {
selectCustomPath.mockReturnValue('existing-path');
selectEntryByPath.mockReturnValue(fromJS({ path: 'existing-path' }));
expect(
validateMetaField(
{
...state,
entryDraft: fromJS({
entry: { path: 'existing-path' },
}),
},
collection,
fromJS({ meta: true, name: 'path' }),
'existing-path',
t,
),
).toEqual({
error: false,
});
});
});
});

View File

@@ -0,0 +1,171 @@
import { Map } from 'immutable';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { mocked } from 'jest-mock';
import { getAsset, ADD_ASSET, LOAD_ASSET_REQUEST } from '../media';
import { selectMediaFilePath } from '../../reducers/entries';
import AssetProxy from '../../valueObjects/AssetProxy';
import type { State } from '../../types/redux';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
const middlewares = [thunk];
const mockStore = configureMockStore<Partial<State>, ThunkDispatch<State, {}, AnyAction>>(
middlewares,
);
const mockedSelectMediaFilePath = mocked(selectMediaFilePath);
jest.mock('../../reducers/entries');
jest.mock('../mediaLibrary');
describe('media', () => {
const emptyAsset = new AssetProxy({
path: 'empty.svg',
file: new File([`<svg xmlns="http://www.w3.org/2000/svg"></svg>`], 'empty.svg', {
type: 'image/svg+xml',
}),
});
describe('getAsset', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.URL = { createObjectURL: jest.fn() };
beforeEach(() => {
jest.resetAllMocks();
});
it('should return empty asset for null path', () => {
const store = mockStore({});
const payload = { collection: null, entryPath: null, entry: null, path: null };
// TODO change to proper payload when immutable is removed
// from 'collections' and 'entries' state slices
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const result = store.dispatch(getAsset(payload));
const actions = store.getActions();
expect(actions).toHaveLength(0);
expect(result).toEqual(emptyAsset);
});
it('should return asset from medias state', () => {
const path = 'static/media/image.png';
const asset = new AssetProxy({ file: new File([], 'empty'), path });
const store = mockStore({
// TODO change to proper store data when immutable is removed
// from 'config' state slice
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
config: Map(),
medias: {
[path]: { asset, isLoading: false, error: null },
},
});
mockedSelectMediaFilePath.mockReturnValue(path);
const payload = { collection: Map(), entry: Map({ path: 'entryPath' }), path };
// TODO change to proper payload when immutable is removed
// from 'collections' and 'entries' state slices
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const result = store.dispatch(getAsset(payload));
const actions = store.getActions();
expect(actions).toHaveLength(0);
expect(result).toBe(asset);
expect(mockedSelectMediaFilePath).toHaveBeenCalledTimes(1);
expect(mockedSelectMediaFilePath).toHaveBeenCalledWith(
store.getState().config,
payload.collection,
payload.entry,
path,
undefined,
);
});
it('should create asset for absolute path when not in medias state', () => {
const path = 'https://asset.netlify.com/image.png';
const asset = new AssetProxy({ url: path, path });
const store = mockStore({
medias: {},
});
mockedSelectMediaFilePath.mockReturnValue(path);
const payload = { collection: null, entryPath: null, path };
// TODO change to proper payload when immutable is removed
// from 'collections' state slice
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const result = store.dispatch(getAsset(payload));
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
type: ADD_ASSET,
payload: asset,
});
expect(result).toEqual(asset);
});
it('should return empty asset and initiate load when not in medias state', () => {
const path = 'static/media/image.png';
const store = mockStore({
medias: {},
});
mockedSelectMediaFilePath.mockReturnValue(path);
const payload = { path };
// TODO change to proper payload when immutable is removed
// from 'collections' and 'entries' state slices
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const result = store.dispatch(getAsset(payload));
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
type: LOAD_ASSET_REQUEST,
payload: { path },
});
expect(result).toEqual(emptyAsset);
});
it('should return asset with original path on load error', () => {
const path = 'static/media/image.png';
const resolvePath = 'resolvePath';
const store = mockStore({
medias: {
[resolvePath]: {
asset: undefined,
error: new Error('test'),
isLoading: false,
},
},
});
mockedSelectMediaFilePath.mockReturnValue(resolvePath);
const payload = { path };
// TODO change to proper payload when immutable is removed
// from 'collections' and 'entries' state slices
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const result = store.dispatch(getAsset(payload));
const actions = store.getActions();
const asset = new AssetProxy({ url: path, path: resolvePath });
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
type: ADD_ASSET,
payload: asset,
});
expect(result).toEqual(asset);
});
});
});

View File

@@ -0,0 +1,327 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { List, Map } from 'immutable';
import { insertMedia, persistMedia, deleteMedia } from '../mediaLibrary';
jest.mock('../../backend');
jest.mock('../waitUntil');
jest.mock('decap-cms-lib-util', () => {
const lib = jest.requireActual('decap-cms-lib-util');
return {
...lib,
getBlobSHA: jest.fn(),
};
});
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('mediaLibrary', () => {
describe('insertMedia', () => {
it('should return mediaPath as string when string is given', () => {
const store = mockStore({
config: {
public_folder: '/media',
},
collections: Map({
posts: Map({ name: 'posts' }),
}),
entryDraft: Map({
entry: Map({ isPersisting: false, collection: 'posts' }),
}),
});
store.dispatch(insertMedia('foo.png'));
expect(store.getActions()[0]).toEqual({
type: 'MEDIA_INSERT',
payload: { mediaPath: '/media/foo.png' },
});
});
it('should return mediaPath as array of strings when array of strings is given', () => {
const store = mockStore({
config: {
public_folder: '/media',
},
collections: Map({
posts: Map({ name: 'posts' }),
}),
entryDraft: Map({
entry: Map({ isPersisting: false, collection: 'posts' }),
}),
});
store.dispatch(insertMedia(['foo.png']));
expect(store.getActions()[0]).toEqual({
type: 'MEDIA_INSERT',
payload: { mediaPath: ['/media/foo.png'] },
});
});
});
const { currentBackend } = require('../../backend');
const backend = {
persistMedia: jest.fn(() => ({ id: 'id' })),
deleteMedia: jest.fn(),
};
currentBackend.mockReturnValue(backend);
describe('persistMedia', () => {
global.URL = { createObjectURL: jest.fn().mockReturnValue('displayURL') };
beforeEach(() => {
jest.clearAllMocks();
});
it('should not persist media when editing draft', () => {
const { getBlobSHA } = require('decap-cms-lib-util');
getBlobSHA.mockReturnValue('000000000000000');
const store = mockStore({
config: {
media_folder: 'static/media',
slug: {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
},
},
collections: Map({
posts: Map({ name: 'posts' }),
}),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map({ isPersisting: false, collection: 'posts' }),
}),
});
const file = new File([''], 'name.png');
return store.dispatch(persistMedia(file)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0].type).toEqual('ADD_ASSET');
expect(actions[0].payload).toEqual(
expect.objectContaining({
path: 'static/media/name.png',
}),
);
expect(actions[1].type).toEqual('ADD_DRAFT_ENTRY_MEDIA_FILE');
expect(actions[1].payload).toEqual(
expect.objectContaining({
draft: true,
id: '000000000000000',
path: 'static/media/name.png',
size: file.size,
name: file.name,
}),
);
expect(getBlobSHA).toHaveBeenCalledTimes(1);
expect(getBlobSHA).toHaveBeenCalledWith(file);
expect(backend.persistMedia).toHaveBeenCalledTimes(0);
});
});
it('should persist media when not editing draft', () => {
const store = mockStore({
config: {
media_folder: 'static/media',
slug: {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
},
},
collections: Map({
posts: Map({ name: 'posts' }),
}),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map(),
}),
});
const file = new File([''], 'name.png');
return store.dispatch(persistMedia(file)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(3);
expect(actions).toHaveLength(3);
expect(actions[0]).toEqual({ type: 'MEDIA_PERSIST_REQUEST' });
expect(actions[1].type).toEqual('ADD_ASSET');
expect(actions[1].payload).toEqual(
expect.objectContaining({
path: 'static/media/name.png',
}),
);
expect(actions[2]).toEqual({
type: 'MEDIA_PERSIST_SUCCESS',
payload: {
file: { id: 'id' },
},
});
expect(backend.persistMedia).toHaveBeenCalledTimes(1);
expect(backend.persistMedia).toHaveBeenCalledWith(
store.getState().config,
expect.objectContaining({
path: 'static/media/name.png',
}),
);
});
});
it('should sanitize media name if needed when persisting', () => {
const store = mockStore({
config: {
media_folder: 'static/media',
slug: {
encoding: 'ascii',
clean_accents: true,
sanitize_replacement: '_',
},
},
collections: Map({
posts: Map({ name: 'posts' }),
}),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map(),
}),
});
const file = new File([''], 'abc DEF éâçÖ $;, .png');
return store.dispatch(persistMedia(file)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(3);
expect(actions[0]).toEqual({ type: 'MEDIA_PERSIST_REQUEST' });
expect(actions[1].type).toEqual('ADD_ASSET');
expect(actions[1].payload).toEqual(
expect.objectContaining({
path: 'static/media/abc_def_eaco_.png',
}),
);
expect(actions[2]).toEqual({
type: 'MEDIA_PERSIST_SUCCESS',
payload: {
file: { id: 'id' },
},
});
expect(backend.persistMedia).toHaveBeenCalledTimes(1);
expect(backend.persistMedia).toHaveBeenCalledWith(
store.getState().config,
expect.objectContaining({
path: 'static/media/abc_def_eaco_.png',
}),
);
});
});
});
describe('deleteMedia', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should delete non draft file', () => {
const store = mockStore({
config: {
publish_mode: 'editorial_workflow',
},
collections: Map(),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map({ isPersisting: false }),
}),
});
const file = { name: 'name.png', id: 'id', path: 'static/media/name.png', draft: false };
return store.dispatch(deleteMedia(file)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(4);
expect(actions[0]).toEqual({ type: 'MEDIA_DELETE_REQUEST' });
expect(actions[1]).toEqual({
type: 'REMOVE_ASSET',
payload: 'static/media/name.png',
});
expect(actions[2]).toEqual({
type: 'MEDIA_DELETE_SUCCESS',
payload: { file },
});
expect(actions[3]).toEqual({
type: 'REMOVE_DRAFT_ENTRY_MEDIA_FILE',
payload: { id: 'id' },
});
expect(backend.deleteMedia).toHaveBeenCalledTimes(1);
expect(backend.deleteMedia).toHaveBeenCalledWith(
store.getState().config,
'static/media/name.png',
);
});
});
it('should not delete a draft file', () => {
const store = mockStore({
config: {
publish_mode: 'editorial_workflow',
},
collections: Map(),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map({ isPersisting: false }),
}),
});
const file = { name: 'name.png', id: 'id', path: 'static/media/name.png', draft: true };
return store.dispatch(deleteMedia(file)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
type: 'REMOVE_ASSET',
payload: 'static/media/name.png',
});
expect(actions[1]).toEqual({
type: 'REMOVE_DRAFT_ENTRY_MEDIA_FILE',
payload: { id: 'id' },
});
expect(backend.deleteMedia).toHaveBeenCalledTimes(0);
});
});
});
});

View File

@@ -0,0 +1,209 @@
import { fromJS } from 'immutable';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { searchEntries } from '../search';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
jest.mock('../../reducers');
jest.mock('../../backend');
jest.mock('../../integrations');
describe('search', () => {
describe('searchEntries', () => {
const { currentBackend } = require('../../backend');
const { selectIntegration } = require('../../reducers');
const { getIntegrationProvider } = require('../../integrations');
beforeEach(() => {
jest.resetAllMocks();
});
it('should search entries in all collections using integration', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: {},
});
selectIntegration.mockReturnValue('search_integration');
currentBackend.mockReturnValue({});
const response = { entries: [{ name: '1' }, { name: '' }], pagination: 1 };
const integration = { search: jest.fn().mockResolvedValue(response) };
getIntegrationProvider.mockReturnValue(integration);
await store.dispatch(searchEntries('find me'));
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
type: 'SEARCH_ENTRIES_REQUEST',
payload: {
searchTerm: 'find me',
searchCollections: ['posts', 'pages'],
page: 0,
},
});
expect(actions[1]).toEqual({
type: 'SEARCH_ENTRIES_SUCCESS',
payload: {
entries: response.entries,
page: response.pagination,
},
});
expect(integration.search).toHaveBeenCalledTimes(1);
expect(integration.search).toHaveBeenCalledWith(['posts', 'pages'], 'find me', 0);
});
it('should search entries in a subset of collections using integration', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: {},
});
selectIntegration.mockReturnValue('search_integration');
currentBackend.mockReturnValue({});
const response = { entries: [{ name: '1' }, { name: '' }], pagination: 1 };
const integration = { search: jest.fn().mockResolvedValue(response) };
getIntegrationProvider.mockReturnValue(integration);
await store.dispatch(searchEntries('find me', ['pages']));
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
type: 'SEARCH_ENTRIES_REQUEST',
payload: {
searchTerm: 'find me',
searchCollections: ['pages'],
page: 0,
},
});
expect(actions[1]).toEqual({
type: 'SEARCH_ENTRIES_SUCCESS',
payload: {
entries: response.entries,
page: response.pagination,
},
});
expect(integration.search).toHaveBeenCalledTimes(1);
expect(integration.search).toHaveBeenCalledWith(['pages'], 'find me', 0);
});
it('should search entries in all collections using backend', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: {},
});
const response = { entries: [{ name: '1' }, { name: '' }], pagination: 1 };
const backend = { search: jest.fn().mockResolvedValue(response) };
currentBackend.mockReturnValue(backend);
await store.dispatch(searchEntries('find me'));
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
type: 'SEARCH_ENTRIES_REQUEST',
payload: {
searchTerm: 'find me',
searchCollections: ['posts', 'pages'],
page: 0,
},
});
expect(actions[1]).toEqual({
type: 'SEARCH_ENTRIES_SUCCESS',
payload: {
entries: response.entries,
page: response.pagination,
},
});
expect(backend.search).toHaveBeenCalledTimes(1);
expect(backend.search).toHaveBeenCalledWith(
[fromJS({ name: 'posts' }), fromJS({ name: 'pages' })],
'find me',
);
});
it('should search entries in a subset of collections using backend', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: {},
});
const response = { entries: [{ name: '1' }, { name: '' }], pagination: 1 };
const backend = { search: jest.fn().mockResolvedValue(response) };
currentBackend.mockReturnValue(backend);
await store.dispatch(searchEntries('find me', ['pages']));
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
type: 'SEARCH_ENTRIES_REQUEST',
payload: {
searchTerm: 'find me',
searchCollections: ['pages'],
page: 0,
},
});
expect(actions[1]).toEqual({
type: 'SEARCH_ENTRIES_SUCCESS',
payload: {
entries: response.entries,
page: response.pagination,
},
});
expect(backend.search).toHaveBeenCalledTimes(1);
expect(backend.search).toHaveBeenCalledWith([fromJS({ name: 'pages' })], 'find me');
});
it('should ignore identical search in all collections', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: { isFetching: true, term: 'find me', collections: ['posts', 'pages'] },
});
await store.dispatch(searchEntries('find me'));
const actions = store.getActions();
expect(actions).toHaveLength(0);
});
it('should ignore identical search in a subset of collections', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: { isFetching: true, term: 'find me', collections: ['pages'] },
});
await store.dispatch(searchEntries('find me', ['pages']));
const actions = store.getActions();
expect(actions).toHaveLength(0);
});
it('should not ignore same search term in different search collections', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: { isFetching: true, term: 'find me', collections: ['pages'] },
});
const backend = { search: jest.fn().mockResolvedValue({}) };
currentBackend.mockReturnValue(backend);
await store.dispatch(searchEntries('find me', ['posts', 'pages']));
expect(backend.search).toHaveBeenCalledTimes(1);
expect(backend.search).toHaveBeenCalledWith(
[fromJS({ name: 'posts' }), fromJS({ name: 'pages' })],
'find me',
);
});
});
});

127
node_modules/decap-cms-core/src/actions/auth.ts generated vendored Normal file
View File

@@ -0,0 +1,127 @@
import { currentBackend } from '../backend';
import { addNotification, clearNotifications } from './notifications';
import type { Credentials, User } from 'decap-cms-lib-util';
import type { ThunkDispatch } from 'redux-thunk';
import type { AnyAction } from 'redux';
import type { State } from '../types/redux';
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 USE_OPEN_AUTHORING = 'USE_OPEN_AUTHORING';
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 useOpenAuthoring() {
return {
type: USE_OPEN_AUTHORING,
} 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<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
dispatch(authenticating());
return Promise.resolve(backend.currentUser())
.then(user => {
if (user) {
if (user.useOpenAuthoring) {
dispatch(useOpenAuthoring());
}
dispatch(authenticate(user));
} else {
dispatch(doneAuthenticating());
}
})
.catch((error: Error) => {
dispatch(authError(error));
dispatch(logoutUser());
});
};
}
export function loginUser(credentials: Credentials) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
dispatch(authenticating());
return backend
.authenticate(credentials)
.then(user => {
if (user.useOpenAuthoring) {
dispatch(useOpenAuthoring());
}
dispatch(authenticate(user));
})
.catch((error: Error) => {
console.error(error);
dispatch(
addNotification({
message: {
details: error.message,
key: 'ui.toast.onFailToAuth',
},
type: 'error',
dismissAfter: 8000,
}),
);
dispatch(authError(error));
});
};
}
export function logoutUser() {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
Promise.resolve(backend.logout()).then(() => {
dispatch(logout());
dispatch(clearNotifications());
});
};
}
export type AuthAction = ReturnType<
| typeof authenticating
| typeof authenticate
| typeof authError
| typeof doneAuthenticating
| typeof logout
>;

18
node_modules/decap-cms-core/src/actions/collections.ts generated vendored Normal file
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));
}

538
node_modules/decap-cms-core/src/actions/config.ts generated vendored Normal file
View File

@@ -0,0 +1,538 @@
import yaml from 'yaml';
import { fromJS } from 'immutable';
import deepmerge from 'deepmerge';
import { produce } from 'immer';
import { trimStart, trim, isEmpty } from 'lodash';
import { SIMPLE as SIMPLE_PUBLISH_MODE } from '../constants/publishModes';
import { validateConfig } from '../constants/configSchema';
import { selectDefaultSortableFields } from '../reducers/collections';
import { getIntegrations, selectIntegration } from '../reducers/integrations';
import { resolveBackend } from '../backend';
import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n';
import { FILES, FOLDER } from '../constants/collectionTypes';
import type { ThunkDispatch } from 'redux-thunk';
import type { AnyAction } from 'redux';
import type {
CmsCollection,
CmsConfig,
CmsField,
CmsFieldBase,
CmsFieldObject,
CmsFieldList,
CmsI18nConfig,
CmsPublishMode,
CmsLocalBackend,
State,
} from '../types/redux';
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
export const CONFIG_SUCCESS = 'CONFIG_SUCCESS';
export const CONFIG_FAILURE = 'CONFIG_FAILURE';
function isObjectField(field: CmsField): field is CmsFieldBase & CmsFieldObject {
return 'fields' in (field as CmsFieldObject);
}
function isFieldList(field: CmsField): field is CmsFieldBase & CmsFieldList {
return 'types' in (field as CmsFieldList) || 'field' in (field as CmsFieldList);
}
function traverseFieldsJS<Field extends CmsField>(
fields: Field[],
updater: <T extends CmsField>(field: T) => T,
): Field[] {
return fields.map(field => {
const newField = updater(field);
if (isObjectField(newField)) {
return { ...newField, fields: traverseFieldsJS(newField.fields, updater) };
} else if (isFieldList(newField) && newField.field) {
return { ...newField, field: traverseFieldsJS([newField.field], updater)[0] };
} 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.log(`Using config file path: "${configLinkEl.href}"`);
return configLinkEl.href;
}
return 'config.yml';
}
function setDefaultPublicFolderForField<T extends CmsField>(field: T) {
if ('media_folder' in field && !('public_folder' in field)) {
return { ...field, public_folder: field.media_folder };
}
return field;
}
// Mapping between existing camelCase and its snake_case counterpart
const WIDGET_KEY_MAP = {
dateFormat: 'date_format',
timeFormat: 'time_format',
pickerUtc: 'picker_utc',
editorComponents: 'editor_components',
valueType: 'value_type',
valueField: 'value_field',
searchFields: 'search_fields',
displayFields: 'display_fields',
optionsLength: 'options_length',
} as const;
function setSnakeCaseConfig<T extends CmsField>(field: T) {
const deprecatedKeys = Object.keys(WIDGET_KEY_MAP).filter(
camel => camel in field,
) as ReadonlyArray<keyof typeof WIDGET_KEY_MAP>;
const snakeValues = deprecatedKeys.map(camel => {
const snake = WIDGET_KEY_MAP[camel];
console.warn(
`Field ${field.name} is using a deprecated configuration '${camel}'. Please use '${snake}'`,
);
return { [snake]: (field as unknown as Record<string, unknown>)[camel] };
});
return Object.assign({}, field, ...snakeValues) as T;
}
function setI18nField<T extends CmsField>(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 | CmsI18nConfig,
defaultI18n: CmsI18nConfig,
) {
if (typeof collectionOrFileI18n === 'boolean') {
return defaultI18n;
} else {
const locales = collectionOrFileI18n.locales || defaultI18n.locales;
const defaultLocale = collectionOrFileI18n.default_locale || locales[0];
const mergedI18n: CmsI18nConfig = deepmerge(defaultI18n, collectionOrFileI18n);
mergedI18n.locales = locales;
mergedI18n.default_locale = defaultLocale;
throwOnMissingDefaultLocale(mergedI18n);
return mergedI18n;
}
}
function setI18nDefaultsForFields(collectionOrFileFields: CmsField[], hasI18n: boolean) {
if (hasI18n) {
return traverseFieldsJS(collectionOrFileFields, setI18nField);
} else {
return traverseFieldsJS(collectionOrFileFields, field => {
const newField = { ...field };
delete newField[I18N];
return newField;
});
}
}
function throwOnInvalidFileCollectionStructure(i18n?: CmsI18nConfig) {
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?: CmsI18nConfig) {
if (i18n && i18n.default_locale && !i18n.locales.includes(i18n.default_locale)) {
throw new Error(
`i18n locales '${i18n.locales.join(', ')}' are missing the default locale ${
i18n.default_locale
}`,
);
}
}
function hasIntegration(config: CmsConfig, collection: CmsCollection) {
// TODO remove fromJS when Immutable is removed from the integrations state slice
const integrations = getIntegrations(fromJS(config));
const integration = selectIntegration(integrations, collection.name, 'listEntries');
return !!integration;
}
export function normalizeConfig(config: CmsConfig) {
const { collections = [] } = config;
const normalizedCollections = collections.map(collection => {
const { fields, files } = collection;
let normalizedCollection = collection;
if (fields) {
const normalizedFields = traverseFieldsJS(fields, setSnakeCaseConfig);
normalizedCollection = { ...normalizedCollection, fields: normalizedFields };
}
if (files) {
const normalizedFiles = files.map(file => {
const normalizedFileFields = traverseFieldsJS(file.fields, setSnakeCaseConfig);
return { ...file, fields: normalizedFileFields };
});
normalizedCollection = { ...normalizedCollection, files: normalizedFiles };
}
if (normalizedCollection.sortableFields) {
const { sortableFields, ...rest } = normalizedCollection;
normalizedCollection = { ...rest, sortable_fields: sortableFields };
console.warn(
`Collection ${collection.name} is using a deprecated configuration 'sortableFields'. Please use 'sortable_fields'`,
);
}
return normalizedCollection;
});
return { ...config, collections: normalizedCollections };
}
export function applyDefaults(originalConfig: CmsConfig) {
return produce(originalConfig, config => {
config.publish_mode = config.publish_mode || SIMPLE_PUBLISH_MODE;
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.default_locale = i18n.default_locale || i18n.locales[0];
}
throwOnMissingDefaultLocale(i18n);
const backend = resolveBackend(config);
for (const collection of config.collections) {
if (!('publish' in collection)) {
collection.publish = true;
}
let collectionI18n = collection[I18N];
if (i18n && collectionI18n) {
collectionI18n = getI18nDefaults(collectionI18n, i18n);
collection[I18N] = collectionI18n;
} else {
collectionI18n = undefined;
delete collection[I18N];
}
if (collection.fields) {
collection.fields = setI18nDefaultsForFields(collection.fields, Boolean(collectionI18n));
}
const { folder, files, view_filters, view_groups, meta } = collection;
if (folder) {
collection.type = 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 (collection.fields) {
collection.fields = traverseFieldsJS(collection.fields, setDefaultPublicFolderForField);
}
collection.folder = trim(folder, '/');
if (meta && meta.path) {
const metaField = {
name: 'path',
meta: true,
required: true,
...meta.path,
};
collection.fields = [metaField, ...(collection.fields || [])];
}
}
if (files) {
collection.type = FILES;
throwOnInvalidFileCollectionStructure(collectionI18n);
delete collection.nested;
delete collection.meta;
for (const file of 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.sortable_fields) {
collection.sortable_fields = selectDefaultSortableFields(
// TODO remove fromJS when Immutable is removed from the collections state slice
fromJS(collection),
backend,
hasIntegration(config, collection),
);
}
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}`,
};
});
if (config.editor && !collection.editor) {
collection.editor = { preview: config.editor.preview };
}
}
});
}
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 CmsConfig>;
for (const key of configKeys) {
config[key] = config[window.CMS_ENV][key] as CmsConfig[keyof CmsConfig];
}
}
return config as Partial<CmsConfig>;
}
async function getConfigYaml(file: string, hasManualConfig: boolean) {
const response = await fetch(file, { credentials: 'same-origin' }).catch(error => error as Error);
if (response instanceof Error || response.status !== 200) {
if (hasManualConfig) {
return {};
}
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.log(`Response for ${file} was not yaml. (Content-Type: ${contentType})`);
if (hasManualConfig) {
return {};
}
}
return parseConfig(await response.text());
}
export function configLoaded(config: CmsConfig) {
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 | CmsLocalBackend) {
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.log(`Looking for Decap CMS Proxy Server at '${proxyUrl}'`);
const res = await fetch(`${proxyUrl}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'info' }),
});
const { repo, publish_modes, type } = (await res.json()) as {
repo?: string;
publish_modes?: CmsPublishMode[];
type?: string;
};
if (typeof repo === 'string' && Array.isArray(publish_modes) && typeof type === 'string') {
console.log(`Detected Decap CMS Proxy Server at '${proxyUrl}' with repo: '${repo}'`);
return { proxyUrl, publish_modes, type };
} else {
console.log(`Decap CMS Proxy Server not detected at '${proxyUrl}'`);
return {};
}
} catch {
console.log(`Decap CMS Proxy Server not detected at '${proxyUrl}'`);
return {};
}
}
function getPublishMode(config: CmsConfig, publishModes?: CmsPublishMode[], backendType?: string) {
if (config.publish_mode && publishModes && !publishModes.includes(config.publish_mode)) {
const newPublishMode = publishModes[0];
console.log(
`'${config.publish_mode}' is not supported by '${backendType}' backend, switching to '${newPublishMode}'`,
);
return newPublishMode;
}
return config.publish_mode;
}
export async function handleLocalBackend(originalConfig: CmsConfig) {
if (!originalConfig.local_backend) {
return originalConfig;
}
const {
proxyUrl,
publish_modes: publishModes,
type: backendType,
} = await detectProxyServer(originalConfig.local_backend);
if (!proxyUrl) {
return originalConfig;
}
return produce(originalConfig, config => {
config.backend.name = 'proxy';
config.backend.proxy_url = proxyUrl;
if (config.publish_mode) {
config.publish_mode = getPublishMode(config, publishModes, backendType);
}
});
}
export function loadConfig(manualConfig: Partial<CmsConfig> = {}, onLoad: () => unknown) {
if (window.CMS_CONFIG) {
return configLoaded(window.CMS_CONFIG);
}
return async (dispatch: ThunkDispatch<State, {}, AnyAction>) => {
dispatch(configLoading());
try {
const configUrl = getConfigUrl();
const hasManualConfig = !isEmpty(manualConfig);
const configYaml =
manualConfig.load_config_file === false
? {}
: await getConfigYaml(configUrl, hasManualConfig);
// Merge manual config into the config.yml one
const mergedConfig = deepmerge(configYaml, manualConfig);
validateConfig(mergedConfig);
const withLocalBackend = await handleLocalBackend(mergedConfig);
const normalizedConfig = normalizeConfig(withLocalBackend);
const config = applyDefaults(normalizedConfig);
dispatch(configLoaded(config));
if (typeof onLoad === 'function') {
onLoad();
}
} catch (err) {
dispatch(configFailed(err));
throw err;
}
};
}
export type ConfigAction = ReturnType<
typeof configLoading | typeof configLoaded | typeof configFailed
>;

104
node_modules/decap-cms-core/src/actions/deploys.ts generated vendored Normal file
View File

@@ -0,0 +1,104 @@
import { currentBackend } from '../backend';
import { selectDeployPreview } from '../reducers';
import { addNotification } from './notifications';
import type { ThunkDispatch } from 'redux-thunk';
import type { AnyAction } from 'redux';
import type { Collection, Entry, State } from '../types/redux';
export const DEPLOY_PREVIEW_REQUEST = 'DEPLOY_PREVIEW_REQUEST';
export const DEPLOY_PREVIEW_SUCCESS = 'DEPLOY_PREVIEW_SUCCESS';
export const DEPLOY_PREVIEW_FAILURE = 'DEPLOY_PREVIEW_FAILURE';
function deployPreviewLoading(collection: string, slug: string) {
return {
type: DEPLOY_PREVIEW_REQUEST,
payload: {
collection,
slug,
},
} as const;
}
function deployPreviewLoaded(
collection: string,
slug: string,
deploy: { url: string | undefined; status: string },
) {
const { url, status } = deploy;
return {
type: DEPLOY_PREVIEW_SUCCESS,
payload: {
collection,
slug,
url,
status,
},
} as const;
}
function deployPreviewError(collection: string, slug: string) {
return {
type: DEPLOY_PREVIEW_FAILURE,
payload: {
collection,
slug,
},
} as const;
}
/**
* Requests a deploy preview object from the registered backend.
*/
export function loadDeployPreview(
collection: Collection,
slug: string,
entry: Entry,
published: boolean,
opts?: { maxAttempts?: number; interval?: number },
) {
return async (dispatch: ThunkDispatch<State, undefined, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
const collectionName = collection.get('name');
// Exit if currently fetching
const deployState = selectDeployPreview(state, collectionName, slug);
if (deployState && deployState.isFetching) {
return;
}
dispatch(deployPreviewLoading(collectionName, slug));
try {
/**
* `getDeploy` is for published entries, while `getDeployPreview` is for
* unpublished entries.
*/
const deploy = published
? backend.getDeploy(collection, slug, entry)
: await backend.getDeployPreview(collection, slug, entry, opts);
if (deploy) {
return dispatch(deployPreviewLoaded(collectionName, slug, deploy));
}
return dispatch(deployPreviewError(collectionName, slug));
} catch (error) {
console.error(error);
dispatch(
addNotification({
message: {
details: error.message,
key: 'ui.toast.onFailToLoadDeployPreview',
},
type: 'error',
dismissAfter: 8000,
}),
);
dispatch(deployPreviewError(collectionName, slug));
}
};
}
export type DeploysAction = ReturnType<
typeof deployPreviewLoading | typeof deployPreviewLoaded | typeof deployPreviewError
>;

View File

@@ -0,0 +1,567 @@
import { get } from 'lodash';
import { Map, List } from 'immutable';
import { EDITORIAL_WORKFLOW_ERROR } from 'decap-cms-lib-util';
import { currentBackend, slugFromCustomPath } from '../backend';
import {
selectPublishedSlugs,
selectUnpublishedSlugs,
selectEntry,
selectUnpublishedEntry,
} from '../reducers';
import { selectEditingDraft } from '../reducers/entries';
import { EDITORIAL_WORKFLOW, status } from '../constants/publishModes';
import {
loadEntry,
entryDeleted,
getMediaAssets,
createDraftFromEntry,
loadEntries,
getSerializedEntry,
} from './entries';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import { addAssets } from './media';
import { loadMedia } from './mediaLibrary';
import ValidationErrorTypes from '../constants/validationErrorTypes';
import { navigateToEntry } from '../routing/history';
import { addNotification } from './notifications';
import type {
Collection,
EntryMap,
State,
Collections,
EntryDraft,
MediaFile,
} from '../types/redux';
import type { AnyAction } from 'redux';
import type { EntryValue } from '../valueObjects/Entry';
import type { Status } from '../constants/publishModes';
import type { ThunkDispatch } from 'redux-thunk';
/*
* Constant Declarations
*/
export const UNPUBLISHED_ENTRY_REQUEST = 'UNPUBLISHED_ENTRY_REQUEST';
export const UNPUBLISHED_ENTRY_SUCCESS = 'UNPUBLISHED_ENTRY_SUCCESS';
export const UNPUBLISHED_ENTRY_REDIRECT = 'UNPUBLISHED_ENTRY_REDIRECT';
export const UNPUBLISHED_ENTRIES_REQUEST = 'UNPUBLISHED_ENTRIES_REQUEST';
export const UNPUBLISHED_ENTRIES_SUCCESS = 'UNPUBLISHED_ENTRIES_SUCCESS';
export const UNPUBLISHED_ENTRIES_FAILURE = 'UNPUBLISHED_ENTRIES_FAILURE';
export const UNPUBLISHED_ENTRY_PERSIST_REQUEST = 'UNPUBLISHED_ENTRY_PERSIST_REQUEST';
export const UNPUBLISHED_ENTRY_PERSIST_SUCCESS = 'UNPUBLISHED_ENTRY_PERSIST_SUCCESS';
export const UNPUBLISHED_ENTRY_PERSIST_FAILURE = 'UNPUBLISHED_ENTRY_PERSIST_FAILURE';
export const UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST';
export const UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS';
export const UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE';
export const UNPUBLISHED_ENTRY_PUBLISH_REQUEST = 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST';
export const UNPUBLISHED_ENTRY_PUBLISH_SUCCESS = 'UNPUBLISHED_ENTRY_PUBLISH_SUCCESS';
export const UNPUBLISHED_ENTRY_PUBLISH_FAILURE = 'UNPUBLISHED_ENTRY_PUBLISH_FAILURE';
export const UNPUBLISHED_ENTRY_DELETE_REQUEST = 'UNPUBLISHED_ENTRY_DELETE_REQUEST';
export const UNPUBLISHED_ENTRY_DELETE_SUCCESS = 'UNPUBLISHED_ENTRY_DELETE_SUCCESS';
export const UNPUBLISHED_ENTRY_DELETE_FAILURE = 'UNPUBLISHED_ENTRY_DELETE_FAILURE';
/*
* Simple Action Creators (Internal)
*/
function unpublishedEntryLoading(collection: Collection, slug: string) {
return {
type: UNPUBLISHED_ENTRY_REQUEST,
payload: {
collection: collection.get('name'),
slug,
},
};
}
function unpublishedEntryLoaded(
collection: Collection,
entry: EntryValue & { mediaFiles: MediaFile[] },
) {
return {
type: UNPUBLISHED_ENTRY_SUCCESS,
payload: {
collection: collection.get('name'),
entry,
},
};
}
function unpublishedEntryRedirected(collection: Collection, slug: string) {
return {
type: UNPUBLISHED_ENTRY_REDIRECT,
payload: {
collection: collection.get('name'),
slug,
},
};
}
function unpublishedEntriesLoading() {
return {
type: UNPUBLISHED_ENTRIES_REQUEST,
};
}
function unpublishedEntriesLoaded(entries: EntryValue[], pagination: number) {
return {
type: UNPUBLISHED_ENTRIES_SUCCESS,
payload: {
entries,
pages: pagination,
},
};
}
function unpublishedEntriesFailed(error: Error) {
return {
type: UNPUBLISHED_ENTRIES_FAILURE,
error: 'Failed to load entries',
payload: error,
};
}
function unpublishedEntryPersisting(collection: Collection, slug: string) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_REQUEST,
payload: {
collection: collection.get('name'),
slug,
},
};
}
function unpublishedEntryPersisted(collection: Collection, entry: EntryMap) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
payload: {
collection: collection.get('name'),
entry,
},
};
}
function unpublishedEntryPersistedFail(error: Error, collection: Collection, slug: string) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_FAILURE,
payload: {
error,
collection: collection.get('name'),
slug,
},
error,
};
}
function unpublishedEntryStatusChangeRequest(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST,
payload: {
collection,
slug,
},
};
}
function unpublishedEntryStatusChangePersisted(
collection: string,
slug: string,
newStatus: Status,
) {
return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS,
payload: {
collection,
slug,
newStatus,
},
};
}
function unpublishedEntryStatusChangeError(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE,
payload: { collection, slug },
};
}
function unpublishedEntryPublishRequest(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_PUBLISH_REQUEST,
payload: { collection, slug },
};
}
function unpublishedEntryPublished(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_PUBLISH_SUCCESS,
payload: { collection, slug },
};
}
function unpublishedEntryPublishError(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_PUBLISH_FAILURE,
payload: { collection, slug },
};
}
function unpublishedEntryDeleteRequest(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_DELETE_REQUEST,
payload: { collection, slug },
};
}
function unpublishedEntryDeleted(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_DELETE_SUCCESS,
payload: { collection, slug },
};
}
function unpublishedEntryDeleteError(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_DELETE_FAILURE,
payload: { collection, slug },
};
}
/*
* Exported Thunk Action Creators
*/
export function loadUnpublishedEntry(collection: Collection, slug: string) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
const entriesLoaded = get(state.editorialWorkflow.toJS(), 'pages.ids', false);
//run possible unpublishedEntries migration
if (!entriesLoaded) {
try {
const { entries, pagination } = await backend.unpublishedEntries(state.collections);
dispatch(unpublishedEntriesLoaded(entries, pagination));
// eslint-disable-next-line no-empty
} catch (e) {}
}
dispatch(unpublishedEntryLoading(collection, slug));
try {
const entry = (await backend.unpublishedEntry(state, collection, slug)) as EntryValue;
const assetProxies = await Promise.all(
entry.mediaFiles
.filter(file => file.draft)
.map(({ url, file, path }) =>
createAssetProxy({
path,
url,
file,
}),
),
);
dispatch(addAssets(assetProxies));
dispatch(unpublishedEntryLoaded(collection, entry));
dispatch(createDraftFromEntry(entry));
} catch (error) {
if (error.name === EDITORIAL_WORKFLOW_ERROR && error.notUnderEditorialWorkflow) {
dispatch(unpublishedEntryRedirected(collection, slug));
dispatch(loadEntry(collection, slug));
} else {
dispatch(
addNotification({
message: {
key: 'ui.toast.onFailToLoadEntries',
details: error,
},
type: 'error',
dismissAfter: 8000,
}),
);
}
}
};
}
export function loadUnpublishedEntries(collections: Collections) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
const entriesLoaded = get(state.editorialWorkflow.toJS(), 'pages.ids', false);
if (state.config.publish_mode !== EDITORIAL_WORKFLOW || entriesLoaded) {
return;
}
dispatch(unpublishedEntriesLoading());
backend
.unpublishedEntries(collections)
.then(response => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination)))
.catch((error: Error) => {
dispatch(
addNotification({
message: {
key: 'ui.toast.onFailToLoadEntries',
details: error,
},
type: 'error',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntriesFailed(error));
Promise.reject(error);
});
};
}
export function persistUnpublishedEntry(collection: Collection, existingUnpublishedEntry: boolean) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const entryDraft = state.entryDraft;
const fieldsErrors = entryDraft.get('fieldsErrors');
const unpublishedSlugs = selectUnpublishedSlugs(state, collection.get('name'));
const publishedSlugs = selectPublishedSlugs(state, collection.get('name'));
const usedSlugs = publishedSlugs.concat(unpublishedSlugs) as List<string>;
const entriesLoaded = get(state.editorialWorkflow.toJS(), 'pages.ids', false);
//load unpublishedEntries
!entriesLoaded && dispatch(loadUnpublishedEntries(state.collections));
// Early return if draft contains validation errors
if (!fieldsErrors.isEmpty()) {
const hasPresenceErrors = fieldsErrors.some(errors =>
errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE),
);
if (hasPresenceErrors) {
dispatch(
addNotification({
message: {
key: 'ui.toast.missingRequiredField',
},
type: 'error',
dismissAfter: 8000,
}),
);
}
return Promise.reject();
}
const backend = currentBackend(state.config);
const entry = entryDraft.get('entry');
const assetProxies = getMediaAssets({
entry,
});
const serializedEntry = getSerializedEntry(collection, entry);
const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
dispatch(unpublishedEntryPersisting(collection, entry.get('slug')));
const persistAction = existingUnpublishedEntry
? backend.persistUnpublishedEntry
: backend.persistEntry;
try {
const newSlug = await persistAction.call(backend, {
config: state.config,
collection,
entryDraft: serializedEntryDraft,
assetProxies,
usedSlugs,
});
dispatch(
addNotification({
message: {
key: 'ui.toast.entrySaved',
},
type: 'success',
dismissAfter: 4000,
}),
);
dispatch(unpublishedEntryPersisted(collection, serializedEntry));
if (entry.get('slug') !== newSlug) {
await dispatch(loadUnpublishedEntry(collection, newSlug));
navigateToEntry(collection.get('name'), newSlug);
}
} catch (error) {
dispatch(
addNotification({
message: {
key: 'ui.toast.onFailToPersist',
details: error,
},
type: 'error',
dismissAfter: 8000,
}),
);
return Promise.reject(
dispatch(unpublishedEntryPersistedFail(error, collection, entry.get('slug'))),
);
}
};
}
export function updateUnpublishedEntryStatus(
collection: string,
slug: string,
oldStatus: Status,
newStatus: Status,
) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
if (oldStatus === newStatus) return;
const state = getState();
const backend = currentBackend(state.config);
dispatch(unpublishedEntryStatusChangeRequest(collection, slug));
backend
.updateUnpublishedEntryStatus(collection, slug, newStatus)
.then(() => {
dispatch(
addNotification({
message: {
key: 'ui.toast.entryUpdated',
},
type: 'success',
dismissAfter: 4000,
}),
);
dispatch(unpublishedEntryStatusChangePersisted(collection, slug, newStatus));
})
.catch((error: Error) => {
dispatch(
addNotification({
message: {
key: 'ui.toast.onFailToUpdateStatus',
details: error,
},
type: 'error',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntryStatusChangeError(collection, slug));
});
};
}
export function deleteUnpublishedEntry(collection: string, slug: string) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
dispatch(unpublishedEntryDeleteRequest(collection, slug));
return backend
.deleteUnpublishedEntry(collection, slug)
.then(() => {
dispatch(
addNotification({
message: { key: 'ui.toast.onDeleteUnpublishedChanges' },
type: 'success',
dismissAfter: 4000,
}),
);
dispatch(unpublishedEntryDeleted(collection, slug));
})
.catch((error: Error) => {
dispatch(
addNotification({
message: { key: 'ui.toast.onDeleteUnpublishedChanges', details: error },
type: 'error',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntryDeleteError(collection, slug));
});
};
}
export function publishUnpublishedEntry(collectionName: string, slug: string) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const collections = state.collections;
const backend = currentBackend(state.config);
const entry = selectUnpublishedEntry(state, collectionName, slug);
dispatch(unpublishedEntryPublishRequest(collectionName, slug));
try {
await backend.publishUnpublishedEntry(entry);
// re-load media after entry was published
dispatch(loadMedia());
dispatch(
addNotification({
message: { key: 'ui.toast.entryPublished' },
type: 'success',
dismissAfter: 4000,
}),
);
dispatch(unpublishedEntryPublished(collectionName, slug));
const collection = collections.get(collectionName);
if (collection.has('nested')) {
dispatch(loadEntries(collection));
const newSlug = slugFromCustomPath(collection, entry.get('path'));
loadEntry(collection, newSlug);
if (slug !== newSlug && selectEditingDraft(state.entryDraft)) {
navigateToEntry(collection.get('name'), newSlug);
}
} else {
return dispatch(loadEntry(collection, slug));
}
} catch (error) {
dispatch(
addNotification({
message: { key: 'ui.toast.onFailToPublishEntry', details: error },
type: 'error',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntryPublishError(collectionName, slug));
}
};
}
export function unpublishPublishedEntry(collection: Collection, slug: string) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
const entry = selectEntry(state, collection.get('name'), slug);
const entryDraft = Map().set('entry', entry) as unknown as EntryDraft;
dispatch(unpublishedEntryPersisting(collection, slug));
return backend
.deleteEntry(state, collection, slug)
.then(() =>
backend.persistEntry({
config: state.config,
collection,
entryDraft,
assetProxies: [],
usedSlugs: List(),
status: status.get('PENDING_PUBLISH'),
}),
)
.then(() => {
dispatch(unpublishedEntryPersisted(collection, entry));
dispatch(entryDeleted(collection, slug));
dispatch(loadUnpublishedEntry(collection, slug));
dispatch(
addNotification({
message: { key: 'ui.toast.entryUnpublished' },
type: 'success',
dismissAfter: 4000,
}),
);
})
.catch((error: Error) => {
dispatch(
addNotification({
message: { key: 'ui.toast.onFailToUnpublishEntry', details: error },
type: 'error',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntryPersistedFail(error, collection, entry.get('slug')));
});
};
}

1041
node_modules/decap-cms-core/src/actions/entries.ts generated vendored Normal file

File diff suppressed because it is too large Load Diff

139
node_modules/decap-cms-core/src/actions/media.ts generated vendored Normal file
View File

@@ -0,0 +1,139 @@
import { isAbsolutePath } from 'decap-cms-lib-util';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import { selectMediaFilePath } from '../reducers/entries';
import { selectMediaFileByPath } from '../reducers/mediaLibrary';
import { getMediaFile, waitForMediaLibraryToLoad, getMediaDisplayURL } from './mediaLibrary';
import type AssetProxy from '../valueObjects/AssetProxy';
import type { Collection, State, EntryMap, EntryField } from '../types/redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { AnyAction } from 'redux';
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 function loadAsset(resolvedPath: string) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
try {
dispatch(loadAssetRequest(resolvedPath));
// load asset url from backend
await waitForMediaLibraryToLoad(dispatch, getState());
const file = selectMediaFileByPath(getState(), resolvedPath);
if (file) {
const url = await getMediaDisplayURL(dispatch, getState(), file);
const asset = createAssetProxy({ path: resolvedPath, url: url || resolvedPath });
dispatch(addAsset(asset));
} else {
const { url } = await getMediaFile(getState(), resolvedPath);
const asset = createAssetProxy({ path: resolvedPath, url });
dispatch(addAsset(asset));
}
dispatch(loadAssetSuccess(resolvedPath));
} catch (e) {
dispatch(loadAssetFailure(resolvedPath, e));
}
};
}
interface GetAssetArgs {
collection: Collection;
entry: EntryMap;
path: string;
field?: EntryField;
}
const emptyAsset = createAssetProxy({
path: 'empty.svg',
file: new File([`<svg xmlns="http://www.w3.org/2000/svg"></svg>`], 'empty.svg', {
type: 'image/svg+xml',
}),
});
export function boundGetAsset(
dispatch: ThunkDispatch<State, {}, AnyAction>,
collection: Collection,
entry: EntryMap,
) {
function bound(path: string, field: EntryField) {
const asset = dispatch(getAsset({ collection, entry, path, field }));
return asset;
}
return bound;
}
export function getAsset({ collection, entry, path, field }: GetAssetArgs) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
if (!path) return emptyAsset;
const state = getState();
const resolvedPath = selectMediaFilePath(state.config, collection, entry, path, field);
let { asset, isLoading, error } = state.medias[resolvedPath] || {};
if (isLoading) {
return emptyAsset;
}
if (asset) {
// There is already an AssetProxy in memory for this path. Use it.
return asset;
}
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));
} else {
if (error) {
// on load error default back to original path
asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset));
} else {
dispatch(loadAsset(resolvedPath));
asset = emptyAsset;
}
}
return asset;
};
}
export type MediasAction = ReturnType<
| typeof addAssets
| typeof addAsset
| typeof removeAsset
| typeof loadAssetRequest
| typeof loadAssetSuccess
| typeof loadAssetFailure
>;

574
node_modules/decap-cms-core/src/actions/mediaLibrary.ts generated vendored Normal file
View File

@@ -0,0 +1,574 @@
import { Map } from 'immutable';
import { basename, getBlobSHA } from 'decap-cms-lib-util';
import { currentBackend } from '../backend';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import { selectIntegration } from '../reducers';
import {
selectMediaFilePath,
selectMediaFilePublicPath,
selectEditingDraft,
} from '../reducers/entries';
import { selectMediaDisplayURL, selectMediaFiles } from '../reducers/mediaLibrary';
import { getIntegrationProvider } from '../integrations';
import { addAsset, removeAsset } from './media';
import { addDraftEntryMediaFile, removeDraftEntryMediaFile } from './entries';
import { sanitizeSlug } from '../lib/urlHelper';
import { waitUntilWithTimeout } from './waitUntil';
import { addNotification } from './notifications';
import type {
State,
MediaFile,
DisplayURLState,
MediaLibraryInstance,
EntryField,
} from '../types/redux';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type AssetProxy from '../valueObjects/AssetProxy';
import type { ImplementationMediaFile } from 'decap-cms-lib-util';
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<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
if (mediaLibrary) {
mediaLibrary.onClearControl({ id });
}
};
}
export function removeMediaControl(id: string) {
return (_dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
if (mediaLibrary) {
mediaLibrary.onRemoveControl({ id });
}
};
}
export function openMediaLibrary(
payload: {
controlID?: string;
forImage?: boolean;
privateUpload?: boolean;
value?: string;
allowMultiple?: boolean;
config?: Map<string, unknown>;
field?: EntryField;
} = {},
) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
if (mediaLibrary) {
const { controlID: id, value, config = Map(), allowMultiple, forImage } = payload;
mediaLibrary.show({ id, value, config: config.toJS(), allowMultiple, imagesOnly: forImage });
}
dispatch(mediaLibraryOpened(payload));
};
}
export function closeMediaLibrary() {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
if (mediaLibrary) {
mediaLibrary.hide();
}
dispatch(mediaLibraryClosed());
};
}
export function insertMedia(mediaPath: string | string[], field: EntryField | undefined) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const config = state.config;
const entry = state.entryDraft.get('entry');
const collectionName = state.entryDraft.getIn(['entry', 'collection']);
const collection = state.collections.get(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; privateUpload?: boolean } = {},
) {
const { delay = 0, query = '', page = 1, privateUpload } = opts;
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
const integration = selectIntegration(state, null, 'assetStore');
if (integration) {
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
dispatch(mediaLoading(page));
try {
const files = await provider.retrieve(query, page, privateUpload);
const mediaLoadedOpts = {
page,
canPaginate: true,
dynamicSearch: true,
dynamicSearchQuery: query,
privateUpload,
};
return dispatch(mediaLoaded(files, mediaLoadedOpts));
} catch (error) {
return dispatch(mediaLoadFailed({ privateUpload }));
}
}
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.log('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 { privateUpload, field } = opts;
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
const integration = selectIntegration(state, null, 'assetStore');
const files: MediaFile[] = selectMediaFiles(state, field);
const fileName = sanitizeSlug(file.name.toLowerCase(), state.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 (!integration && existingFile) {
if (!window.confirm(`${existingFile.name} already exists. Do you want to replace it?`)) {
return;
} else {
await dispatch(deleteMedia(existingFile, { privateUpload }));
}
}
if (integration || !editingDraft) {
dispatch(mediaPersisting());
}
try {
let assetProxy: AssetProxy;
if (integration) {
try {
const provider = getIntegrationProvider(
state.integrations,
backend.getToken,
integration,
);
const response = await provider.upload(file, privateUpload);
assetProxy = createAssetProxy({
url: response.asset.url,
path: response.asset.url,
});
} catch (error) {
assetProxy = createAssetProxy({
file,
path: fileName,
});
}
} else if (privateUpload) {
throw new Error('The Private Upload option is only available for Asset Store Integration');
} else {
const entry = state.entryDraft.get('entry');
const collection = state.collections.get(entry?.get('collection'));
const path = selectMediaFilePath(state.config, collection, entry, fileName, field);
assetProxy = createAssetProxy({
file,
path,
field,
});
}
dispatch(addAsset(assetProxy));
let mediaFile: ImplementationMediaFile;
if (integration) {
const id = await getBlobSHA(file);
// integration assets are persisted immediately, thus draft is false
mediaFile = createMediaFileFromAsset({ id, file, assetProxy, draft: false });
} else if (editingDraft) {
const id = await getBlobSHA(file);
mediaFile = createMediaFileFromAsset({
id,
file,
assetProxy,
draft: editingDraft,
});
return dispatch(addDraftEntryMediaFile(mediaFile));
} else {
mediaFile = await backend.persistMedia(state.config, assetProxy);
}
return dispatch(mediaPersisted(mediaFile, { privateUpload }));
} catch (error) {
console.error(error);
dispatch(
addNotification({
message: `Failed to persist media: ${error}`,
type: 'error',
dismissAfter: 8000,
}),
);
return dispatch(mediaPersistFailed({ privateUpload }));
}
};
}
export function deleteMedia(file: MediaFile, opts: MediaOptions = {}) {
const { privateUpload } = opts;
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
const integration = selectIntegration(state, null, 'assetStore');
if (integration) {
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
dispatch(mediaDeleting());
try {
await provider.delete(file.id);
return dispatch(mediaDeleted(file, { privateUpload }));
} catch (error) {
console.error(error);
dispatch(
addNotification({
message: `Failed to delete media: ${error.message}`,
type: 'error',
dismissAfter: 8000,
}),
);
return dispatch(mediaDeleteFailed({ privateUpload }));
}
}
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(state.config, file.path);
dispatch(mediaDeleted(file));
if (editingDraft) {
dispatch(removeDraftEntryMediaFile({ id: file.id }));
}
}
} catch (error) {
console.error(error);
dispatch(
addNotification({
message: `Failed to delete media: ${error.message}`,
type: 'error',
dismissAfter: 8000,
}),
);
return dispatch(mediaDeleteFailed());
}
};
}
export async function getMediaFile(state: State, path: string) {
const backend = currentBackend(state.config);
const { url } = await backend.getMediaFile(path);
return { url };
}
export function loadMediaDisplayURL(file: MediaFile) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const { displayURL, id } = file;
const state = getState();
const displayURLState: DisplayURLState = selectMediaDisplayURL(state, id);
if (
!id ||
!displayURL ||
displayURLState.get('url') ||
displayURLState.get('isFetching') ||
displayURLState.get('err')
) {
return Promise.resolve();
}
if (typeof displayURL === 'string') {
dispatch(mediaDisplayURLRequest(id));
dispatch(mediaDisplayURLSuccess(id, displayURL));
return;
}
try {
const backend = currentBackend(state.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 (err) {
console.error(err);
dispatch(mediaDisplayURLFailure(id, err));
}
};
}
function mediaLibraryOpened(payload: {
controlID?: string;
forImage?: boolean;
privateUpload?: boolean;
value?: string;
replaceIndex?: number;
allowMultiple?: boolean;
config?: Map<string, unknown>;
field?: EntryField;
}) {
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;
}
interface MediaOptions {
privateUpload?: boolean;
field?: EntryField;
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(opts: MediaOptions = {}) {
const { privateUpload } = opts;
return { type: MEDIA_LOAD_FAILURE, payload: { privateUpload } } as const;
}
export function mediaPersisting() {
return { type: MEDIA_PERSIST_REQUEST } as const;
}
export function mediaPersisted(file: ImplementationMediaFile, opts: MediaOptions = {}) {
const { privateUpload } = opts;
return {
type: MEDIA_PERSIST_SUCCESS,
payload: { file, privateUpload },
} as const;
}
export function mediaPersistFailed(opts: MediaOptions = {}) {
const { privateUpload } = opts;
return { type: MEDIA_PERSIST_FAILURE, payload: { privateUpload } } as const;
}
export function mediaDeleting() {
return { type: MEDIA_DELETE_REQUEST } as const;
}
export function mediaDeleted(file: MediaFile, opts: MediaOptions = {}) {
const { privateUpload } = opts;
return {
type: MEDIA_DELETE_SUCCESS,
payload: { file, privateUpload },
} as const;
}
export function mediaDeleteFailed(opts: MediaOptions = {}) {
const { privateUpload } = opts;
return { type: MEDIA_DELETE_FAILURE, payload: { privateUpload } } 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<State, {}, AnyAction>,
state: State,
) {
if (state.mediaLibrary.get('isLoading') !== false && !state.mediaLibrary.get('externalLibrary')) {
await waitUntilWithTimeout(dispatch, resolve => ({
predicate: ({ type }) => type === MEDIA_LOAD_SUCCESS || type === MEDIA_LOAD_FAILURE,
run: () => resolve(),
}));
}
}
export async function getMediaDisplayURL(
dispatch: ThunkDispatch<State, {}, AnyAction>,
state: State,
file: MediaFile,
) {
const displayURLState: DisplayURLState = selectMediaDisplayURL(state, file.id);
let url: string | null | undefined;
if (displayURLState.get('url')) {
// url was already loaded
url = displayURLState.get('url');
} else if (displayURLState.get('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.get('isFetching')) {
// load display url
dispatch(loadMediaDisplayURL(file));
}
url = (await promise) ?? null;
}
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,36 @@
import type { TypeOptions } from 'react-toastify';
export interface NotificationMessage {
details?: unknown;
key: string;
}
export interface NotificationPayload {
message: string | NotificationMessage;
dismissAfter?: number;
type: TypeOptions | undefined;
}
export const NOTIFICATION_SEND = 'NOTIFICATION_SEND';
export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS';
export const NOTIFICATIONS_CLEAR = 'NOTIFICATION_CLEAR';
function addNotification(notification: NotificationPayload) {
return { type: NOTIFICATION_SEND, payload: notification };
}
function dismissNotification(id: string) {
return { type: NOTIFICATION_DISMISS, id };
}
function clearNotifications() {
return { type: NOTIFICATIONS_CLEAR };
}
export type NotificationsAction = {
type: typeof NOTIFICATION_DISMISS | typeof NOTIFICATION_SEND | typeof NOTIFICATIONS_CLEAR;
payload?: NotificationPayload;
id?: string;
};
export { addNotification, dismissNotification, clearNotifications };

221
node_modules/decap-cms-core/src/actions/search.ts generated vendored Normal file
View File

@@ -0,0 +1,221 @@
import { isEqual } from 'lodash';
import { currentBackend } from '../backend';
import { getIntegrationProvider } from '../integrations';
import { selectIntegration } from '../reducers';
import type { QueryRequest } from '../reducers/search';
import type { State } from '../types/redux';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { EntryValue } from '../valueObjects/Entry';
/*
* 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';
export const CLEAR_REQUESTS = 'CLEAR_REQUESTS';
/*
* 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: EntryValue[], 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, request?: QueryRequest) {
return {
type: QUERY_REQUEST,
payload: {
searchTerm,
request,
},
} as const;
}
type SearchResponse = {
entries: EntryValue[];
pagination: number;
};
type QueryResponse = {
hits: EntryValue[];
query: string;
};
export function querySuccess(namespace: string, hits: EntryValue[]) {
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;
}
export function clearRequests() {
return { type: CLEAR_REQUESTS } 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<State, undefined, AnyAction>, getState: () => State) => {
const state = getState();
const { search } = state;
const backend = currentBackend(state.config);
const allCollections = searchCollections || state.collections.keySeq().toArray();
const collections = allCollections.filter(collection =>
selectIntegration(state, collection, 'search'),
);
const integration = selectIntegration(state, collections[0], 'search');
// avoid duplicate searches
if (
search.isFetching &&
search.term === searchTerm &&
isEqual(allCollections, search.collections) &&
// if an integration doesn't exist, 'page' is not used
(search.page === page || !integration)
) {
return;
}
dispatch(searchingEntries(searchTerm, allCollections, page));
const searchPromise = integration
? getIntegrationProvider(state.integrations, backend.getToken, integration).search(
collections,
searchTerm,
page,
)
: backend.search(
state.collections
.filter((_, key: string) => allCollections.indexOf(key) !== -1)
.valueSeq()
.toArray(),
searchTerm,
);
try {
const response: SearchResponse = await searchPromise;
return dispatch(searchSuccess(response.entries, response.pagination));
} catch (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<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
const integration = selectIntegration(state, collectionName, 'search');
const collection = state.collections.find(
collection => collection.get('name') === collectionName,
);
dispatch(clearRequests());
const queryIdentifier = `${collectionName}-${searchFields.join()}-${searchTerm}-${file}-${limit}`;
const queuedQueryPromise = state.search.requests.find(({ id }) => id == queryIdentifier);
const queryPromise = queuedQueryPromise
? queuedQueryPromise.queryResponse
: integration
? getIntegrationProvider(state.integrations, backend.getToken, integration).searchBy(
searchFields.map(f => `data.${f}`),
collectionName,
searchTerm,
)
: backend.query(collection, searchFields, searchTerm, file, limit);
dispatch(
querying(
searchTerm,
queuedQueryPromise
? undefined
: {
id: queryIdentifier,
expires: new Date(new Date().getTime() + 10 * 1000),
queryResponse: queryPromise,
},
),
);
try {
const response: QueryResponse = await queryPromise;
return dispatch(querySuccess(namespace, response.hits));
} catch (error) {
return dispatch(queryFailure(error));
}
};
}
export type SearchAction = ReturnType<
| typeof searchingEntries
| typeof searchSuccess
| typeof searchFailure
| typeof querying
| typeof querySuccess
| typeof queryFailure
| typeof clearSearch
| typeof clearRequests
>;

99
node_modules/decap-cms-core/src/actions/status.ts generated vendored Normal file
View File

@@ -0,0 +1,99 @@
import { currentBackend } from '../backend';
import { addNotification, dismissNotification } from './notifications';
import type { ThunkDispatch } from 'redux-thunk';
import type { AnyAction } from 'redux';
import type { State } from '../types/redux';
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<State, {}, AnyAction>, getState: () => State) => {
try {
const state = getState();
if (state.status.isFetching) {
return;
}
dispatch(statusRequest());
const backend = currentBackend(state.config);
const status = await backend.status();
const backendDownKey = 'ui.toast.onBackendDown';
const previousBackendDownNotifications = state.notifications.notifications.filter(
n => typeof n.message != 'string' && n.message?.key === backendDownKey,
);
if (status.api.status === false) {
if (previousBackendDownNotifications.length === 0) {
dispatch(
addNotification({
message: {
details: status.api.statusPage,
key: 'ui.toast.onBackendDown',
},
type: 'error',
}),
);
}
return dispatch(statusSuccess(status));
} else if (status.api.status === true && previousBackendDownNotifications.length > 0) {
// If backend is up, clear all the danger messages
previousBackendDownNotifications.forEach(notification => {
dispatch(dismissNotification(notification.id));
});
}
const authError = status.auth.status === false;
if (authError) {
const key = 'ui.toast.onLoggedOut';
const existingNotification = state.notifications.notifications.find(
n => typeof n.message != 'string' && n.message?.key === key,
);
if (!existingNotification) {
dispatch(
addNotification({
message: {
key: 'ui.toast.onLoggedOut',
},
type: 'error',
}),
);
}
}
dispatch(statusSuccess(status));
} catch (error) {
dispatch(statusFailure(error));
}
};
}
export type StatusAction = ReturnType<
typeof statusRequest | typeof statusSuccess | typeof statusFailure
>;

49
node_modules/decap-cms-core/src/actions/waitUntil.ts generated vendored Normal file
View File

@@ -0,0 +1,49 @@
import { WAIT_UNTIL_ACTION } from '../redux/middleware/waitUntilAction';
import type { WaitActionArgs } from '../redux/middleware/waitUntilAction';
import type { ThunkDispatch } from 'redux-thunk';
import type { AnyAction } from 'redux';
import type { State } from '../types/redux';
export function waitUntil({ predicate, run }: WaitActionArgs) {
return {
type: WAIT_UNTIL_ACTION,
predicate,
run,
};
}
export async function waitUntilWithTimeout<T>(
dispatch: ThunkDispatch<State, {}, AnyAction>,
waitActionArgs: (resolve: (value?: T) => void) => WaitActionArgs,
timeout = 30000,
): Promise<T | null | void> {
let waitDone = false;
const waitPromise = new Promise<T>(resolve => {
dispatch(waitUntil(waitActionArgs(resolve as (value?: T | undefined) => void)));
});
const timeoutPromise = new Promise<T | null | void>(resolve => {
setTimeout(() => {
if (waitDone) {
resolve();
} 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;
}