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

View File

@@ -0,0 +1,38 @@
import { authenticating, authenticate, authError, logout } from '../../actions/auth';
import auth, { defaultState } from '../auth';
describe('auth', () => {
it('should handle an empty state', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore auth reducer doesn't accept empty action
expect(auth(undefined, {})).toEqual(defaultState);
});
it('should handle an authentication request', () => {
expect(auth(undefined, authenticating())).toEqual({
...defaultState,
isFetching: true,
});
});
it('should handle authentication', () => {
const user = { name: 'joe', token: 'token' };
expect(auth(undefined, authenticate(user))).toEqual({
...defaultState,
user,
});
});
it('should handle an authentication error', () => {
expect(auth(undefined, authError(new Error('Bad credentials')))).toEqual({
...defaultState,
error: 'Error: Bad credentials',
});
});
it('should handle logout', () => {
const user = { name: 'joe', token: 'token' };
const newState = auth({ ...defaultState, user }, logout());
expect(newState.user).toBeUndefined();
});
});

View File

@@ -0,0 +1,571 @@
import { fromJS, Map } from 'immutable';
import { configLoaded } from '../../actions/config';
import collections, {
selectAllowDeletion,
selectEntryPath,
selectEntrySlug,
selectFieldsWithMediaFolders,
selectMediaFolders,
selectEntryCollectionTitle,
getFieldsNames,
selectField,
updateFieldByKey,
} from '../collections';
import { FILES, FOLDER } from '../../constants/collectionTypes';
describe('collections', () => {
it('should handle an empty state', () => {
expect(collections(undefined, {})).toEqual(Map());
});
it('should load the collections from the config', () => {
expect(
collections(
undefined,
configLoaded({
collections: [
{
name: 'posts',
folder: '_posts',
fields: [{ name: 'title', widget: 'string' }],
},
],
}),
).toJS(),
).toEqual({
posts: {
name: 'posts',
folder: '_posts',
fields: [{ name: 'title', widget: 'string' }],
},
});
});
it('should maintain config collections order', () => {
const collectionsData = new Array(1000).fill(0).map((_, index) => ({
name: `collection_${index}`,
folder: `collection_${index}`,
fields: [{ name: 'title', widget: 'string' }],
}));
const newState = collections(
undefined,
configLoaded({
collections: collectionsData,
}),
);
const keyArray = newState.keySeq().toArray();
expect(keyArray).toEqual(collectionsData.map(({ name }) => name));
});
describe('selectAllowDeletions', () => {
it('should not allow deletions for file collections', () => {
expect(
selectAllowDeletion(
fromJS({
name: 'pages',
type: FILES,
}),
),
).toBe(false);
});
});
describe('selectEntryPath', () => {
it('should return path', () => {
expect(
selectEntryPath(
fromJS({
type: FOLDER,
folder: 'posts',
}),
'dir1/dir2/slug',
),
).toBe('posts/dir1/dir2/slug.md');
});
});
describe('selectEntrySlug', () => {
it('should return slug', () => {
expect(
selectEntrySlug(
fromJS({
type: FOLDER,
folder: 'posts',
}),
'posts/dir1/dir2/slug.md',
),
).toBe('dir1/dir2/slug');
});
});
describe('selectFieldsMediaFolders', () => {
it('should return empty array for invalid collection', () => {
expect(selectFieldsWithMediaFolders(fromJS({}))).toEqual([]);
});
it('should return configs for folder collection', () => {
expect(
selectFieldsWithMediaFolders(
fromJS({
folder: 'posts',
fields: [
{
name: 'image',
media_folder: 'image_media_folder',
},
{
name: 'body',
media_folder: 'body_media_folder',
},
{
name: 'list_1',
field: {
name: 'list_1_item',
media_folder: 'list_1_item_media_folder',
},
},
{
name: 'list_2',
fields: [
{
name: 'list_2_item',
media_folder: 'list_2_item_media_folder',
},
],
},
{
name: 'list_3',
types: [
{
name: 'list_3_type',
media_folder: 'list_3_type_media_folder',
},
],
},
],
}),
),
).toEqual([
fromJS({
name: 'image',
media_folder: 'image_media_folder',
}),
fromJS({ name: 'body', media_folder: 'body_media_folder' }),
fromJS({ name: 'list_1_item', media_folder: 'list_1_item_media_folder' }),
fromJS({
name: 'list_2_item',
media_folder: 'list_2_item_media_folder',
}),
fromJS({
name: 'list_3_type',
media_folder: 'list_3_type_media_folder',
}),
]);
});
it('should return configs for files collection', () => {
expect(
selectFieldsWithMediaFolders(
fromJS({
files: [
{
name: 'file1',
fields: [
{
name: 'image',
media_folder: 'image_media_folder',
},
],
},
{
name: 'file2',
fields: [
{
name: 'body',
media_folder: 'body_media_folder',
},
],
},
{
name: 'file3',
fields: [
{
name: 'list_1',
field: {
name: 'list_1_item',
media_folder: 'list_1_item_media_folder',
},
},
],
},
{
name: 'file4',
fields: [
{
name: 'list_2',
fields: [
{
name: 'list_2_item',
media_folder: 'list_2_item_media_folder',
},
{
name: 'list_3',
types: [
{
name: 'list_3_type',
media_folder: 'list_3_type_media_folder',
},
],
},
],
},
],
},
],
}),
'file4',
),
).toEqual([
fromJS({
name: 'list_2_item',
media_folder: 'list_2_item_media_folder',
}),
fromJS({
name: 'list_3_type',
media_folder: 'list_3_type_media_folder',
}),
]);
});
});
describe('selectMediaFolders', () => {
const slug = {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
};
const config = { slug, media_folder: '/static/img' };
it('should return fields and collection folders', () => {
expect(
selectMediaFolders(
config,
fromJS({
folder: 'posts',
media_folder: '{{media_folder}}/general/',
fields: [
{
name: 'image',
media_folder: '{{media_folder}}/customers/',
},
{
name: 'list',
types: [{ name: 'widget', media_folder: '{{media_folder}}/widgets' }],
},
],
}),
fromJS({ slug: 'name', path: 'src/post/post1.md', data: {} }),
),
).toEqual([
'static/img/general',
'static/img/general/customers',
'static/img/general/widgets',
]);
});
it('should return fields, file and collection folders', () => {
expect(
selectMediaFolders(
config,
fromJS({
media_folder: '{{media_folder}}/general/',
files: [
{
name: 'name',
file: 'src/post/post1.md',
media_folder: '{{media_folder}}/customers/',
fields: [
{
name: 'image',
media_folder: '{{media_folder}}/logos/',
},
{
name: 'list',
types: [{ name: 'widget', media_folder: '{{media_folder}}/widgets' }],
},
],
},
],
}),
fromJS({ slug: 'name', path: 'src/post/post1.md', data: {} }),
),
).toEqual([
'static/img/general',
'static/img/general/customers',
'static/img/general/customers/logos',
'static/img/general/customers/widgets',
]);
});
});
describe('getFieldsNames', () => {
it('should get flat fields names', () => {
const collection = fromJS({
fields: [{ name: 'en' }, { name: 'es' }],
});
expect(getFieldsNames(collection.get('fields').toArray())).toEqual(['en', 'es']);
});
it('should get nested fields names', () => {
const collection = fromJS({
fields: [
{ name: 'en', fields: [{ name: 'title' }, { name: 'body' }] },
{ name: 'es', fields: [{ name: 'title' }, { name: 'body' }] },
{ name: 'it', field: { name: 'title', fields: [{ name: 'subTitle' }] } },
{
name: 'fr',
fields: [{ name: 'title', widget: 'list', types: [{ name: 'variableType' }] }],
},
],
});
expect(getFieldsNames(collection.get('fields').toArray())).toEqual([
'en',
'es',
'it',
'fr',
'en.title',
'en.body',
'es.title',
'es.body',
'it.title',
'it.title.subTitle',
'fr.title',
'fr.title.variableType',
]);
});
});
describe('selectField', () => {
it('should return top field by key', () => {
const collection = fromJS({
fields: [{ name: 'en' }, { name: 'es' }],
});
expect(selectField(collection, 'en')).toBe(collection.get('fields').get(0));
});
it('should return nested field by key', () => {
const collection = fromJS({
fields: [
{ name: 'en', fields: [{ name: 'title' }, { name: 'body' }] },
{ name: 'es', fields: [{ name: 'title' }, { name: 'body' }] },
{ name: 'it', field: { name: 'title', fields: [{ name: 'subTitle' }] } },
{
name: 'fr',
fields: [{ name: 'title', widget: 'list', types: [{ name: 'variableType' }] }],
},
],
});
expect(selectField(collection, 'en.title')).toBe(
collection.get('fields').get(0).get('fields').get(0),
);
expect(selectField(collection, 'it.title.subTitle')).toBe(
collection.get('fields').get(2).get('field').get('fields').get(0),
);
expect(selectField(collection, 'fr.title.variableType')).toBe(
collection.get('fields').get(3).get('fields').get(0).get('types').get(0),
);
});
});
describe('selectEntryCollectionTitle', () => {
const entry = fromJS({
data: { title: 'entry title', otherField: 'other field', emptyLinkTitle: '' },
});
it('should return the entry title if set', () => {
const collection = fromJS({
fields: [{ name: 'title' }, { name: 'otherField' }],
});
expect(selectEntryCollectionTitle(collection, entry)).toEqual('entry title');
});
it('should return some other inferreable title if set', () => {
const headlineEntry = fromJS({
data: { headline: 'entry headline', otherField: 'other field' },
});
const collection = fromJS({
fields: [{ name: 'headline' }, { name: 'otherField' }],
});
expect(selectEntryCollectionTitle(collection, headlineEntry)).toEqual('entry headline');
});
it('should return the identifier_field content if defined in collection', () => {
const collection = fromJS({
identifier_field: 'otherField',
fields: [{ name: 'title' }, { name: 'otherField' }],
});
expect(selectEntryCollectionTitle(collection, entry)).toEqual('other field');
});
it('should return the entry title if identifier_field content is not defined in collection', () => {
const collection = fromJS({
identifier_field: 'missingLinkTitle',
fields: [{ name: 'title' }, { name: 'otherField' }],
});
expect(selectEntryCollectionTitle(collection, entry)).toEqual('entry title');
});
it('should return the entry title if identifier_field content is empty', () => {
const collection = fromJS({
identifier_field: 'emptyLinkTitle',
fields: [{ name: 'title' }, { name: 'otherField' }, { name: 'emptyLinkTitle' }],
});
expect(selectEntryCollectionTitle(collection, entry)).toEqual('entry title');
});
it('should return the entry label of a file collection', () => {
const labelEntry = fromJS({
slug: 'entry-name',
data: { title: 'entry title', otherField: 'other field' },
});
const collection = fromJS({
type: FILES,
files: [
{
name: 'entry-name',
label: 'entry label',
},
],
});
expect(selectEntryCollectionTitle(collection, labelEntry)).toEqual('entry label');
});
it('should return a formatted summary before everything else', () => {
const collection = fromJS({
summary: '{{title}} -- {{otherField}}',
identifier_field: 'otherField',
fields: [{ name: 'title' }, { name: 'otherField' }],
});
expect(selectEntryCollectionTitle(collection, entry)).toEqual('entry title -- other field');
});
});
describe('updateFieldByKey', () => {
it('should update field by key', () => {
const collection = fromJS({
fields: [
{ name: 'title' },
{ name: 'image' },
{
name: 'object',
fields: [{ name: 'title' }, { name: 'gallery', fields: [{ name: 'image' }] }],
},
{ name: 'list', field: { name: 'image' } },
{ name: 'body' },
{ name: 'widgetList', types: [{ name: 'widget' }] },
],
});
function updater(field) {
return field.set('default', 'default');
}
expect(updateFieldByKey(collection, 'non-existent', updater)).toBe(collection);
expect(updateFieldByKey(collection, 'title', updater)).toEqual(
fromJS({
fields: [
{ name: 'title', default: 'default' },
{ name: 'image' },
{
name: 'object',
fields: [{ name: 'title' }, { name: 'gallery', fields: [{ name: 'image' }] }],
},
{ name: 'list', field: { name: 'image' } },
{ name: 'body' },
{ name: 'widgetList', types: [{ name: 'widget' }] },
],
}),
);
expect(updateFieldByKey(collection, 'object.title', updater)).toEqual(
fromJS({
fields: [
{ name: 'title' },
{ name: 'image' },
{
name: 'object',
fields: [
{ name: 'title', default: 'default' },
{ name: 'gallery', fields: [{ name: 'image' }] },
],
},
{ name: 'list', field: { name: 'image' } },
{ name: 'body' },
{ name: 'widgetList', types: [{ name: 'widget' }] },
],
}),
);
expect(updateFieldByKey(collection, 'object.gallery.image', updater)).toEqual(
fromJS({
fields: [
{ name: 'title' },
{ name: 'image' },
{
name: 'object',
fields: [
{ name: 'title' },
{ name: 'gallery', fields: [{ name: 'image', default: 'default' }] },
],
},
{ name: 'list', field: { name: 'image' } },
{ name: 'body' },
{ name: 'widgetList', types: [{ name: 'widget' }] },
],
}),
);
expect(updateFieldByKey(collection, 'list.image', updater)).toEqual(
fromJS({
fields: [
{ name: 'title' },
{ name: 'image' },
{
name: 'object',
fields: [{ name: 'title' }, { name: 'gallery', fields: [{ name: 'image' }] }],
},
{ name: 'list', field: { name: 'image', default: 'default' } },
{ name: 'body' },
{ name: 'widgetList', types: [{ name: 'widget' }] },
],
}),
);
expect(updateFieldByKey(collection, 'widgetList.widget', updater)).toEqual(
fromJS({
fields: [
{ name: 'title' },
{ name: 'image' },
{
name: 'object',
fields: [{ name: 'title' }, { name: 'gallery', fields: [{ name: 'image' }] }],
},
{ name: 'list', field: { name: 'image' } },
{ name: 'body' },
{ name: 'widgetList', types: [{ name: 'widget', default: 'default' }] },
],
}),
);
});
});
});

View File

@@ -0,0 +1,38 @@
import { configLoaded, configLoading, configFailed } from '../../actions/config';
import config, { selectLocale } from '../config';
describe('config', () => {
it('should handle an empty state', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore config reducer doesn't accept empty action
expect(config(undefined, {})).toEqual({ isFetching: true });
});
it('should handle an update', () => {
expect(
config({ isFetching: true }, configLoaded({ locale: 'fr', backend: { name: 'proxy' } })),
).toEqual({
locale: 'fr',
backend: { name: 'proxy' },
isFetching: false,
error: undefined,
});
});
it('should mark the config as loading', () => {
expect(config({ isFetching: false }, configLoading())).toEqual({ isFetching: true });
});
it('should handle an error', () => {
expect(
config({ isFetching: true }, configFailed(new Error('Config could not be loaded'))),
).toEqual({
error: 'Error: Config could not be loaded',
isFetching: false,
});
});
it('should default to "en" locale', () => {
expect(selectLocale({})).toEqual('en');
});
});

View File

@@ -0,0 +1,694 @@
import { OrderedMap, fromJS } from 'immutable';
import * as actions from '../../actions/entries';
import reducer, {
selectMediaFolder,
selectMediaFilePath,
selectMediaFilePublicPath,
selectEntries,
} from '../entries';
const initialState = OrderedMap({
posts: fromJS({ name: 'posts' }),
});
describe('entries', () => {
describe('reducer', () => {
it('should mark entries as fetching', () => {
expect(reducer(initialState, actions.entriesLoading(fromJS({ name: 'posts' })))).toEqual(
OrderedMap(
fromJS({
posts: { name: 'posts' },
pages: {
posts: { isFetching: true },
},
}),
),
);
});
it('should handle loaded entries', () => {
const entries = [
{ slug: 'a', path: '' },
{ slug: 'b', title: 'B' },
];
expect(
reducer(initialState, actions.entriesLoaded(fromJS({ name: 'posts' }), entries, 0)),
).toEqual(
OrderedMap(
fromJS({
posts: { name: 'posts' },
entities: {
'posts.a': { slug: 'a', path: '', isFetching: false },
'posts.b': { slug: 'b', title: 'B', isFetching: false },
},
pages: {
posts: {
page: 0,
ids: ['a', 'b'],
},
},
}),
),
);
});
it('should handle loaded entry', () => {
const entry = { slug: 'a', path: '' };
expect(reducer(initialState, actions.entryLoaded(fromJS({ name: 'posts' }), entry))).toEqual(
OrderedMap(
fromJS({
posts: { name: 'posts' },
entities: {
'posts.a': { slug: 'a', path: '' },
},
pages: {
posts: {
ids: ['a'],
},
},
}),
),
);
});
});
describe('selectMediaFolder', () => {
it("should return global media folder when collection doesn't specify media_folder", () => {
expect(
selectMediaFolder(
{ media_folder: 'static/media' },
fromJS({ name: 'posts' }),
undefined,
undefined,
),
).toEqual('static/media');
});
it('should return draft media folder when collection specifies media_folder and entry is undefined', () => {
expect(
selectMediaFolder(
{ media_folder: 'static/media' },
fromJS({ name: 'posts', folder: 'posts', media_folder: '' }),
undefined,
undefined,
),
).toEqual('posts/DRAFT_MEDIA_FILES');
});
it('should return relative media folder when collection specifies media_folder and entry path is not null', () => {
expect(
selectMediaFolder(
{ media_folder: 'static/media' },
fromJS({ name: 'posts', folder: 'posts', media_folder: '' }),
fromJS({ path: 'posts/title/index.md' }),
undefined,
),
).toEqual('posts/title');
});
it('should resolve collection relative media folder', () => {
expect(
selectMediaFolder(
fromJS({ media_folder: 'static/media' }),
fromJS({ name: 'posts', folder: 'posts', media_folder: '../' }),
fromJS({ path: 'posts/title/index.md' }),
undefined,
),
).toEqual('posts');
});
it('should resolve field relative media folder', () => {
const field = fromJS({ media_folder: '' });
expect(
selectMediaFolder(
{ media_folder: '/static/img' },
fromJS({
name: 'other',
folder: 'other',
fields: [field],
media_folder: '../',
}),
fromJS({ path: 'src/other/other.md', data: {} }),
field,
),
).toEqual('src/other');
});
it('should return collection absolute media folder without leading slash', () => {
expect(
selectMediaFolder(
{ media_folder: '/static/Images' },
fromJS({
name: 'getting-started',
folder: 'src/docs/getting-started',
media_folder: '/static/images/docs/getting-started',
}),
fromJS({}),
undefined,
),
).toEqual('static/images/docs/getting-started');
});
it('should compile relative media folder template', () => {
const slugConfig = {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
};
const entry = fromJS({
path: 'content/en/hosting-and-deployment/deployment-with-nanobox.md',
data: { title: 'Deployment With NanoBox', category: 'Hosting And Deployment' },
});
const collection = fromJS({
name: 'posts',
folder: 'content',
media_folder: '../../../{{media_folder}}/{{category}}/{{slug}}',
fields: [{ name: 'title', widget: 'string' }],
});
expect(
selectMediaFolder(
{ media_folder: 'static/media', slug: slugConfig },
collection,
entry,
undefined,
),
).toEqual('static/media/hosting-and-deployment/deployment-with-nanobox');
});
it('should compile absolute media folder template', () => {
const slugConfig = {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
};
const entry = fromJS({
data: { title: 'Overview' },
});
const collection = fromJS({
name: 'extending',
folder: 'src/docs/extending',
media_folder: '{{media_folder}}/docs/extending',
fields: [{ name: 'title', widget: 'string' }],
});
expect(
selectMediaFolder(
{ media_folder: '/static/images', slug: slugConfig },
collection,
entry,
undefined,
),
).toEqual('static/images/docs/extending');
});
it('should compile field media folder template', () => {
const slugConfig = {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
};
const entry = fromJS({
path: 'content/en/hosting-and-deployment/deployment-with-nanobox.md',
data: { title: 'Deployment With NanoBox', category: 'Hosting And Deployment' },
});
const collection = fromJS({
name: 'posts',
folder: 'content',
fields: [
{
name: 'title',
widget: 'string',
media_folder: '../../../{{media_folder}}/{{category}}/{{slug}}',
},
],
});
expect(
selectMediaFolder(
{ media_folder: 'static/media', slug: slugConfig },
collection,
entry,
collection.get('fields').get(0),
),
).toEqual('static/media/hosting-and-deployment/deployment-with-nanobox');
});
it('should handle double slashes', () => {
const slugConfig = {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
};
const entry = fromJS({
path: 'content/en/hosting-and-deployment/deployment-with-nanobox.md',
data: { title: 'Deployment With NanoBox', category: 'Hosting And Deployment' },
});
const collection = fromJS({
name: 'posts',
folder: 'content',
media_folder: '{{media_folder}}/blog',
fields: [{ name: 'title', widget: 'string' }],
});
expect(
selectMediaFolder(
{ media_folder: '/static/img/', slug: slugConfig },
collection,
entry,
undefined,
),
).toEqual('static/img/blog');
expect(
selectMediaFolder(
{ media_folder: 'static/img/', slug: slugConfig },
collection,
entry,
undefined,
),
).toEqual('content/en/hosting-and-deployment/static/img/blog');
});
it('should handle file media_folder', () => {
expect(
selectMediaFolder(
{ media_folder: 'static/media' },
fromJS({ name: 'posts', files: [{ name: 'index', media_folder: '/static/images/' }] }),
fromJS({ path: 'posts/title/index.md', slug: 'index' }),
undefined,
),
).toBe('static/images');
});
it('should cascade media_folders', () => {
const mainImageField = fromJS({ name: 'main_image' });
const logoField = fromJS({ name: 'logo', media_folder: '{{media_folder}}/logos/' });
const nestedField3 = fromJS({ name: 'nested', media_folder: '{{media_folder}}/nested3/' });
const nestedField2 = fromJS({
name: 'nested',
media_folder: '{{media_folder}}/nested2/',
types: [nestedField3],
});
const nestedField1 = fromJS({
name: 'nested',
media_folder: '{{media_folder}}/nested1/',
fields: [nestedField2],
});
const args = [
{ media_folder: '/static/img' },
fromJS({
name: 'general',
media_folder: '{{media_folder}}/general/',
files: [
{
name: 'customers',
media_folder: '{{media_folder}}/customers/',
fields: [
mainImageField,
logoField,
{ media_folder: '{{media_folder}}/nested', field: nestedField1 },
],
},
],
}),
fromJS({ path: 'src/customers/customers.md', slug: 'customers', data: { title: 'title' } }),
];
expect(selectMediaFolder(...args, mainImageField)).toBe('static/img/general/customers');
expect(selectMediaFolder(...args, logoField)).toBe('static/img/general/customers/logos');
expect(selectMediaFolder(...args, nestedField1)).toBe(
'static/img/general/customers/nested/nested1',
);
expect(selectMediaFolder(...args, nestedField2)).toBe(
'static/img/general/customers/nested/nested1/nested2',
);
expect(selectMediaFolder(...args, nestedField3)).toBe(
'static/img/general/customers/nested/nested1/nested2/nested3',
);
});
});
describe('selectMediaFilePath', () => {
it('should return absolute URL as is', () => {
expect(selectMediaFilePath(null, null, null, 'https://www.netlify.com/image.png')).toBe(
'https://www.netlify.com/image.png',
);
});
it('should resolve path from global media folder for collection with no media folder', () => {
expect(
selectMediaFilePath(
{ media_folder: 'static/media' },
fromJS({ name: 'posts', folder: 'posts' }),
undefined,
'image.png',
undefined,
),
).toBe('static/media/image.png');
});
it('should resolve path from collection media folder for collection with media folder', () => {
expect(
selectMediaFilePath(
{ media_folder: 'static/media' },
fromJS({ name: 'posts', folder: 'posts', media_folder: '' }),
undefined,
'image.png',
undefined,
),
).toBe('posts/DRAFT_MEDIA_FILES/image.png');
});
it('should handle relative media_folder', () => {
expect(
selectMediaFilePath(
{ media_folder: 'static/media' },
fromJS({ name: 'posts', folder: 'posts', media_folder: '../../static/media/' }),
fromJS({ path: 'posts/title/index.md' }),
'image.png',
undefined,
),
).toBe('static/media/image.png');
});
it('should handle field media_folder', () => {
const field = fromJS({ media_folder: '../../static/media/' });
expect(
selectMediaFilePath(
{ media_folder: 'static/media' },
fromJS({ name: 'posts', folder: 'posts', fields: [field] }),
fromJS({ path: 'posts/title/index.md' }),
'image.png',
field,
),
).toBe('static/media/image.png');
});
});
describe('selectMediaFilePublicPath', () => {
it('should return absolute URL as is', () => {
expect(selectMediaFilePublicPath(null, null, 'https://www.netlify.com/image.png')).toBe(
'https://www.netlify.com/image.png',
);
});
it('should resolve path from public folder for collection with no media folder', () => {
expect(
selectMediaFilePublicPath(
{ public_folder: '/media' },
null,
'/media/image.png',
undefined,
undefined,
),
).toBe('/media/image.png');
});
it('should resolve path from collection public folder for collection with public folder', () => {
expect(
selectMediaFilePublicPath(
{ public_folder: '/media' },
fromJS({ name: 'posts', folder: 'posts', public_folder: '' }),
'image.png',
undefined,
undefined,
),
).toBe('image.png');
});
it('should handle relative public_folder', () => {
expect(
selectMediaFilePublicPath(
{ public_folder: '/media' },
fromJS({ name: 'posts', folder: 'posts', public_folder: '../../static/media/' }),
'image.png',
undefined,
undefined,
),
).toBe('../../static/media/image.png');
});
it('should handle absolute public_folder', () => {
expect(
selectMediaFilePublicPath(
{ public_folder: 'https://www.netlify.com/media' },
fromJS({
name: 'posts',
folder: 'posts',
public_folder: 'https://www.netlify.com/media',
}),
'image.png',
undefined,
undefined,
),
).toBe('https://www.netlify.com/media/image.png');
});
it('should compile collection public folder template', () => {
const slugConfig = {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
};
const entry = fromJS({
path: 'content/en/hosting-and-deployment/deployment-with-nanobox.md',
data: { title: 'Deployment With NanoBox', category: 'Hosting And Deployment' },
});
const collection = fromJS({
name: 'posts',
folder: 'content',
public_folder: '/{{public_folder}}/{{category}}/{{slug}}',
fields: [{ name: 'title', widget: 'string' }],
});
expect(
selectMediaFilePublicPath(
{ public_folder: 'static/media', slug: slugConfig },
collection,
'image.png',
entry,
undefined,
),
).toEqual('/static/media/hosting-and-deployment/deployment-with-nanobox/image.png');
});
it('should compile field public folder template', () => {
const slugConfig = {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
};
const entry = fromJS({
path: 'content/en/hosting-and-deployment/deployment-with-nanobox.md',
data: { title: 'Deployment With NanoBox', category: 'Hosting And Deployment' },
});
const field = fromJS({
name: 'title',
widget: 'string',
public_folder: '/{{public_folder}}/{{category}}/{{slug}}',
});
const collection = fromJS({
name: 'posts',
folder: 'content',
fields: [field],
});
expect(
selectMediaFilePublicPath(
{ public_folder: 'static/media', slug: slugConfig },
collection,
'image.png',
entry,
field,
),
).toEqual('/static/media/hosting-and-deployment/deployment-with-nanobox/image.png');
});
it('should handle double slashes', () => {
const slugConfig = {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
};
const entry = fromJS({
path: 'content/en/hosting-and-deployment/deployment-with-nanobox.md',
data: { title: 'Deployment With NanoBox', category: 'Hosting And Deployment' },
});
const field = fromJS({
name: 'title',
widget: 'string',
public_folder: '/{{public_folder}}/{{category}}/{{slug}}',
});
const collection = fromJS({
name: 'posts',
folder: 'content',
fields: [field],
});
expect(
selectMediaFilePublicPath(
{ public_folder: 'static/media/', slug: slugConfig },
collection,
'image.png',
entry,
field,
),
).toEqual('/static/media/hosting-and-deployment/deployment-with-nanobox/image.png');
});
it('should handle file public_folder', () => {
const entry = fromJS({
path: 'src/posts/index.md',
slug: 'index',
});
const collection = fromJS({
name: 'posts',
files: [
{
name: 'index',
public_folder: '/images',
fields: [{ name: 'title', widget: 'string' }],
},
],
});
expect(
selectMediaFilePublicPath(
{ public_folder: 'static/media/' },
collection,
'image.png',
entry,
undefined,
),
).toBe('/images/image.png');
});
});
describe('selectEntries', () => {
it('should return all entries', () => {
const state = fromJS({
entities: {
'posts.1': { slug: '1' },
'posts.2': { slug: '2' },
'posts.3': { slug: '3' },
'posts.4': { slug: '4' },
},
pages: { posts: { ids: ['1', '2', '3', '4'] } },
});
const collection = fromJS({
name: 'posts',
});
expect(selectEntries(state, collection)).toEqual(
fromJS([{ slug: '1' }, { slug: '2' }, { slug: '3' }, { slug: '4' }]),
);
});
});
it('should return sorted entries entries by field', () => {
const state = fromJS({
entities: {
'posts.1': { slug: '1', data: { title: '1' } },
'posts.2': { slug: '2', data: { title: '2' } },
'posts.3': { slug: '3', data: { title: '3' } },
'posts.4': { slug: '4', data: { title: '4' } },
},
pages: { posts: { ids: ['1', '2', '3', '4'] } },
sort: { posts: { title: { key: 'title', direction: 'Descending' } } },
});
const collection = fromJS({
name: 'posts',
});
expect(selectEntries(state, collection)).toEqual(
fromJS([
{ slug: '4', data: { title: '4' } },
{ slug: '3', data: { title: '3' } },
{ slug: '2', data: { title: '2' } },
{ slug: '1', data: { title: '1' } },
]),
);
});
it('should return sorted entries entries by nested field', () => {
const state = fromJS({
entities: {
'posts.1': { slug: '1', data: { title: '1', nested: { date: 4 } } },
'posts.2': { slug: '2', data: { title: '2', nested: { date: 3 } } },
'posts.3': { slug: '3', data: { title: '3', nested: { date: 2 } } },
'posts.4': { slug: '4', data: { title: '4', nested: { date: 1 } } },
},
pages: { posts: { ids: ['1', '2', '3', '4'] } },
sort: { posts: { title: { key: 'nested.date', direction: 'Ascending' } } },
});
const collection = fromJS({
name: 'posts',
});
expect(selectEntries(state, collection)).toEqual(
fromJS([
{ slug: '4', data: { title: '4', nested: { date: 1 } } },
{ slug: '3', data: { title: '3', nested: { date: 2 } } },
{ slug: '2', data: { title: '2', nested: { date: 3 } } },
{ slug: '1', data: { title: '1', nested: { date: 4 } } },
]),
);
});
it('should return filtered entries entries by field', () => {
const state = fromJS({
entities: {
'posts.1': { slug: '1', data: { title: '1' } },
'posts.2': { slug: '2', data: { title: '2' } },
'posts.3': { slug: '3', data: { title: '3' } },
'posts.4': { slug: '4', data: { title: '4' } },
},
pages: { posts: { ids: ['1', '2', '3', '4'] } },
filter: { posts: { title__1: { field: 'title', pattern: '4', active: true } } },
});
const collection = fromJS({
name: 'posts',
});
expect(selectEntries(state, collection)).toEqual(fromJS([{ slug: '4', data: { title: '4' } }]));
});
it('should return filtered entries entries by nested field', () => {
const state = fromJS({
entities: {
'posts.1': { slug: '1', data: { title: '1', nested: { draft: true } } },
'posts.2': { slug: '2', data: { title: '2', nested: { draft: true } } },
'posts.3': { slug: '3', data: { title: '3', nested: { draft: false } } },
'posts.4': { slug: '4', data: { title: '4', nested: { draft: false } } },
},
pages: { posts: { ids: ['1', '2', '3', '4'] } },
filter: {
posts: { 'nested.draft__false': { field: 'nested.draft', pattern: false, active: true } },
},
});
const collection = fromJS({
name: 'posts',
});
expect(selectEntries(state, collection)).toEqual(
fromJS([
{ slug: '3', data: { title: '3', nested: { draft: false } } },
{ slug: '4', data: { title: '4', nested: { draft: false } } },
]),
);
});
});

View File

@@ -0,0 +1,198 @@
import { Map, fromJS } from 'immutable';
import * as actions from '../../actions/entries';
import reducer from '../entryDraft';
jest.mock('uuid', () => ({ v4: jest.fn(() => '1') }));
const initialState = Map({
entry: Map(),
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
key: '',
});
const entry = {
collection: 'posts',
slug: 'slug',
path: 'content/blog/art-and-wine-festival.md',
partial: false,
raw: '',
data: {},
metaData: null,
};
describe('entryDraft reducer', () => {
describe('DRAFT_CREATE_FROM_ENTRY', () => {
it('should create draft from the entry', () => {
const state = reducer(initialState, actions.createDraftFromEntry(fromJS(entry)));
expect(state).toEqual(
fromJS({
entry: {
...entry,
newRecord: false,
},
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
key: '1',
}),
);
});
});
describe('DRAFT_CREATE_EMPTY', () => {
it('should create a new draft ', () => {
const state = reducer(initialState, actions.emptyDraftCreated(fromJS(entry)));
expect(state).toEqual(
fromJS({
entry: {
...entry,
newRecord: true,
},
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
key: '1',
}),
);
});
});
describe('DRAFT_DISCARD', () => {
it('should discard the draft and return initial state', () => {
expect(reducer(initialState, actions.discardDraft())).toEqual(initialState);
});
});
describe('persisting', () => {
let initialState;
beforeEach(() => {
initialState = fromJS({
entities: {
'posts.slug': {
collection: 'posts',
slug: 'slug',
path: 'content/blog/art-and-wine-festival.md',
partial: false,
raw: '',
data: {},
metaData: null,
},
},
pages: {},
});
});
it('should handle persisting request', () => {
const newState = reducer(
initialState,
actions.entryPersisting(Map({ name: 'posts' }), Map({ slug: 'slug' })),
);
expect(newState.getIn(['entry', 'isPersisting'])).toBe(true);
});
it('should handle persisting success', () => {
let newState = reducer(
initialState,
actions.entryPersisting(Map({ name: 'posts' }), Map({ slug: 'slug' })),
);
newState = reducer(
newState,
actions.entryPersisted(Map({ name: 'posts' }), Map({ slug: 'slug' })),
);
expect(newState.getIn(['entry', 'isPersisting'])).toBeUndefined();
});
it('should handle persisting error', () => {
let newState = reducer(
initialState,
actions.entryPersisting(Map({ name: 'posts' }), Map({ slug: 'slug' })),
);
newState = reducer(
newState,
actions.entryPersistFail(Map({ name: 'posts' }), Map({ slug: 'slug' }), 'Error message'),
);
expect(newState.getIn(['entry', 'isPersisting'])).toBeUndefined();
});
});
describe('REMOVE_DRAFT_ENTRY_MEDIA_FILE', () => {
it('should remove a media file', () => {
const actualState = reducer(
initialState.setIn(['entry', 'mediaFiles'], fromJS([{ id: '1' }, { id: '2' }])),
actions.removeDraftEntryMediaFile({ id: '1' }),
);
expect(actualState.toJS()).toEqual({
entry: { mediaFiles: [{ id: '2' }] },
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: true,
key: '',
});
});
});
describe('ADD_DRAFT_ENTRY_MEDIA_FILE', () => {
it('should overwrite an existing media file', () => {
const actualState = reducer(
initialState.setIn(['entry', 'mediaFiles'], fromJS([{ id: '1', name: 'old' }])),
actions.addDraftEntryMediaFile({ id: '1', name: 'new' }),
);
expect(actualState.toJS()).toEqual({
entry: { mediaFiles: [{ id: '1', name: 'new' }] },
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: true,
key: '',
});
});
});
describe('DRAFT_CREATE_FROM_LOCAL_BACKUP', () => {
it('should create draft from local backup', () => {
const localBackup = Map({ entry: fromJS({ ...entry, mediaFiles: [{ id: '1' }] }) });
const actualState = reducer(initialState.set('localBackup', localBackup), {
type: actions.DRAFT_CREATE_FROM_LOCAL_BACKUP,
});
expect(actualState.toJS()).toEqual({
entry: {
...entry,
mediaFiles: [{ id: '1' }],
newRecord: false,
},
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: true,
key: '1',
});
});
});
describe('DRAFT_LOCAL_BACKUP_RETRIEVED', () => {
it('should set local backup', () => {
const mediaFiles = [{ id: '1' }];
const actualState = reducer(
initialState,
actions.localBackupRetrieved({ ...entry, mediaFiles }),
);
expect(actualState.toJS()).toEqual({
entry: {},
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: false,
localBackup: {
entry: { ...entry, mediaFiles: [{ id: '1' }] },
},
key: '',
});
});
});
});

View File

@@ -0,0 +1,43 @@
import { USE_OPEN_AUTHORING } from '../../actions/auth';
import {
DEPLOY_PREVIEW_REQUEST,
DEPLOY_PREVIEW_SUCCESS,
DEPLOY_PREVIEW_FAILURE,
} from '../../actions/deploys';
import { ENTRY_REQUEST, ENTRY_SUCCESS, ENTRY_FAILURE } from '../../actions/entries';
import reducer from '../globalUI';
describe('globalUI', () => {
it('should set isFetching to true on entry request', () => {
expect(reducer({ isFetching: false }, { type: ENTRY_REQUEST })).toEqual({ isFetching: true });
});
it('should set isFetching to false on entry success', () => {
expect(reducer({ isFetching: true }, { type: ENTRY_SUCCESS })).toEqual({ isFetching: false });
});
it('should set isFetching to false on entry failure', () => {
expect(reducer({ isFetching: true }, { type: ENTRY_FAILURE })).toEqual({ isFetching: false });
});
it('should not change state on deploy preview request', () => {
const state = { isFetching: false };
expect(reducer(state, { type: DEPLOY_PREVIEW_REQUEST })).toBe(state);
});
it('should not change state on deploy preview success', () => {
const state = { isFetching: true };
expect(reducer(state, { type: DEPLOY_PREVIEW_SUCCESS })).toBe(state);
});
it('should not change state on deploy preview failure', () => {
const state = { isFetching: true };
expect(reducer(state, { type: DEPLOY_PREVIEW_FAILURE })).toBe(state);
});
it('should set useOpenAuthoring to true on USE_OPEN_AUTHORING', () => {
expect(reducer({ useOpenAuthoring: false }, { type: USE_OPEN_AUTHORING })).toEqual({
useOpenAuthoring: true,
});
});
});

View File

@@ -0,0 +1,76 @@
import integrations from '../integrations';
import { CONFIG_SUCCESS } from '../../actions/config';
import { FOLDER } from '../../constants/collectionTypes';
import type { ConfigAction } from '../../actions/config';
describe('integrations', () => {
it('should return default state when no integrations', () => {
const result = integrations(null, {
type: CONFIG_SUCCESS,
payload: { integrations: [] },
} as ConfigAction);
expect(result && result.toJS()).toEqual({
providers: {},
hooks: {},
});
});
it('should return hooks and providers map when has integrations', () => {
const result = integrations(null, {
type: CONFIG_SUCCESS,
payload: {
integrations: [
{
hooks: ['listEntries'],
collections: '*',
provider: 'algolia',
applicationID: 'applicationID',
apiKey: 'apiKey',
},
{
hooks: ['listEntries'],
collections: ['posts'],
provider: 'algolia',
applicationID: 'applicationID',
apiKey: 'apiKey',
},
{
hooks: ['assetStore'],
provider: 'assetStore',
getSignedFormURL: 'https://asset.store.com/signedUrl',
},
],
collections: [
{ name: 'posts', label: 'Posts', type: FOLDER },
{ name: 'pages', label: 'Pages', type: FOLDER },
{ name: 'faq', label: 'FAQ', type: FOLDER },
],
},
} as ConfigAction);
expect(result && result.toJS()).toEqual({
providers: {
algolia: {
applicationID: 'applicationID',
apiKey: 'apiKey',
},
assetStore: {
getSignedFormURL: 'https://asset.store.com/signedUrl',
},
},
hooks: {
posts: {
listEntries: 'algolia',
},
pages: {
listEntries: 'algolia',
},
faq: {
listEntries: 'algolia',
},
assetStore: 'assetStore',
},
});
});
});

View File

@@ -0,0 +1,154 @@
import { Map, fromJS } from 'immutable';
import { mediaDeleted } from '../../actions/mediaLibrary';
import mediaLibrary, {
selectMediaFiles,
selectMediaFileByPath,
selectMediaDisplayURL,
} from '../mediaLibrary';
jest.mock('uuid');
jest.mock('../entries');
jest.mock('../');
describe('mediaLibrary', () => {
it('should remove media file by key', () => {
expect(
mediaLibrary(
Map({
files: [{ key: 'key1' }, { key: 'key2' }],
}),
mediaDeleted({ key: 'key1' }),
),
).toEqual(
Map({
isDeleting: false,
files: [{ key: 'key2' }],
}),
);
});
it('should remove media file by id', () => {
expect(
mediaLibrary(
Map({
files: [{ id: 'id1' }, { id: 'id2' }],
}),
mediaDeleted({ id: 'id1' }),
),
).toEqual(
Map({
isDeleting: false,
files: [{ id: 'id2' }],
}),
);
});
it('should select draft media files from field when editing a draft', () => {
const { selectEditingDraft, selectMediaFolder } = require('../../reducers/entries');
selectEditingDraft.mockReturnValue(true);
selectMediaFolder.mockReturnValue('/static/images/posts/logos');
const imageField = fromJS({ name: 'image' });
const collection = fromJS({ fields: [imageField] });
const entry = fromJS({
collection: 'posts',
mediaFiles: [
{ id: 1, path: '/static/images/posts/logos/logo.png' },
{ id: 2, path: '/static/images/posts/general/image.png' },
{ id: 3, path: '/static/images/posts/index.png' },
],
data: {},
});
const state = {
config: {},
collections: fromJS({ posts: collection }),
entryDraft: fromJS({
entry,
}),
};
expect(selectMediaFiles(state, imageField)).toEqual([
{ id: 1, key: 1, path: '/static/images/posts/logos/logo.png' },
]);
expect(selectMediaFolder).toHaveBeenCalledWith(state.config, collection, entry, imageField);
});
it('should select draft media files from collection when editing a draft', () => {
const { selectEditingDraft, selectMediaFolder } = require('../../reducers/entries');
selectEditingDraft.mockReturnValue(true);
selectMediaFolder.mockReturnValue('/static/images/posts');
const imageField = fromJS({ name: 'image' });
const collection = fromJS({ fields: [imageField] });
const entry = fromJS({
collection: 'posts',
mediaFiles: [
{ id: 1, path: '/static/images/posts/logos/logo.png' },
{ id: 2, path: '/static/images/posts/general/image.png' },
{ id: 3, path: '/static/images/posts/index.png' },
],
data: {},
});
const state = {
config: {},
collections: fromJS({ posts: collection }),
entryDraft: fromJS({
entry,
}),
};
expect(selectMediaFiles(state, imageField)).toEqual([
{ id: 3, key: 3, path: '/static/images/posts/index.png' },
]);
expect(selectMediaFolder).toHaveBeenCalledWith(state.config, collection, entry, imageField);
});
it('should select global media files when not editing a draft', () => {
const { selectEditingDraft } = require('../../reducers/entries');
selectEditingDraft.mockReturnValue(false);
const state = {
mediaLibrary: Map({ files: [{ id: 1 }] }),
};
expect(selectMediaFiles(state)).toEqual([{ id: 1 }]);
});
it('should select global media files when not using asset store integration', () => {
const { selectIntegration } = require('../../reducers');
selectIntegration.mockReturnValue({});
const state = {
mediaLibrary: Map({ files: [{ id: 1 }] }),
};
expect(selectMediaFiles(state)).toEqual([{ id: 1 }]);
});
it('should return media file by path', () => {
const { selectEditingDraft } = require('../../reducers/entries');
selectEditingDraft.mockReturnValue(false);
const state = {
mediaLibrary: Map({ files: [{ id: 1, path: 'path' }] }),
};
expect(selectMediaFileByPath(state, 'path')).toEqual({ id: 1, path: 'path' });
});
it('should return media display URL state', () => {
const state = {
mediaLibrary: fromJS({ displayURLs: { id: { url: 'url' } } }),
};
expect(selectMediaDisplayURL(state, 'id')).toEqual(Map({ url: 'url' }));
});
});

View File

@@ -0,0 +1,49 @@
import {
addAssets,
addAsset,
removeAsset,
loadAssetRequest,
loadAssetSuccess,
loadAssetFailure,
} from '../../actions/media';
import reducer from '../medias';
import { createAssetProxy } from '../../valueObjects/AssetProxy';
describe('medias', () => {
const asset = createAssetProxy({ url: 'url', path: 'path' });
it('should add assets', () => {
expect(reducer({}, addAssets([asset]))).toEqual({
path: { asset, isLoading: false, error: null },
});
});
it('should add asset', () => {
expect(reducer({}, addAsset(asset))).toEqual({
path: { asset, isLoading: false, error: null },
});
});
it('should remove asset', () => {
expect(
reducer({ [asset.path]: { asset, isLoading: false, error: null } }, removeAsset(asset.path)),
).toEqual({});
});
it('should mark asset as loading', () => {
expect(reducer({}, loadAssetRequest(asset.path))).toEqual({ path: { isLoading: true } });
});
it('should mark asset as not loading', () => {
expect(reducer({}, loadAssetSuccess(asset.path))).toEqual({
path: { isLoading: false, error: null },
});
});
it('should set loading error', () => {
const error = new Error('some error');
expect(reducer({}, loadAssetFailure(asset.path, error))).toEqual({
path: { isLoading: false, error },
});
});
});

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

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

487
node_modules/decap-cms-core/src/reducers/collections.ts generated vendored Normal file
View File

@@ -0,0 +1,487 @@
import { List, Set, fromJS, OrderedMap } from 'immutable';
import { get, escapeRegExp } from 'lodash';
import { stringTemplate } from 'decap-cms-lib-widgets';
import consoleError from '../lib/consoleError';
import { CONFIG_SUCCESS } from '../actions/config';
import { FILES, FOLDER } from '../constants/collectionTypes';
import { COMMIT_DATE, COMMIT_AUTHOR } from '../constants/commitProps';
import { INFERABLE_FIELDS, IDENTIFIER_FIELDS, SORTABLE_FIELDS } from '../constants/fieldInference';
import { getFormatExtensions } from '../formats/formats';
import { selectMediaFolder } from './entries';
import { summaryFormatter } from '../lib/formatters';
import type {
Collection,
Collections,
CollectionFiles,
EntryField,
EntryMap,
ViewFilter,
ViewGroup,
CmsConfig,
} from '../types/redux';
import type { ConfigAction } from '../actions/config';
import type { Backend } from '../backend';
const { keyToPathArray } = stringTemplate;
const defaultState: Collections = fromJS({});
function collections(state = defaultState, action: ConfigAction) {
switch (action.type) {
case CONFIG_SUCCESS: {
const collections = action.payload.collections;
let newState = OrderedMap({});
collections.forEach(collection => {
newState = newState.set(collection.name, fromJS(collection));
});
return newState;
}
default:
return state;
}
}
const selectors = {
[FOLDER]: {
entryExtension(collection: Collection) {
const ext =
collection.get('extension') ||
get(getFormatExtensions(), collection.get('format') || 'frontmatter');
if (!ext) {
throw new Error(`No extension found for format ${collection.get('format')}`);
}
return ext.replace(/^\./, '');
},
fields(collection: Collection) {
return collection.get('fields');
},
entryPath(collection: Collection, slug: string) {
const folder = (collection.get('folder') as string).replace(/\/$/, '');
return `${folder}/${slug}.${this.entryExtension(collection)}`;
},
entrySlug(collection: Collection, path: string) {
const folder = (collection.get('folder') as string).replace(/\/$/, '');
const slug = path
.split(folder + '/')
.pop()
?.replace(new RegExp(`\\.${escapeRegExp(this.entryExtension(collection))}$`), '');
return slug;
},
allowNewEntries(collection: Collection) {
return collection.get('create');
},
allowDeletion(collection: Collection) {
return collection.get('delete', true);
},
templateName(collection: Collection) {
return collection.get('name');
},
},
[FILES]: {
fileForEntry(collection: Collection, slug: string) {
const files = collection.get('files');
return files && files.filter(f => f?.get('name') === slug).get(0);
},
fields(collection: Collection, slug: string) {
const file = this.fileForEntry(collection, slug);
return file && file.get('fields');
},
entryPath(collection: Collection, slug: string) {
const file = this.fileForEntry(collection, slug);
return file && file.get('file');
},
entrySlug(collection: Collection, path: string) {
const file = (collection.get('files') as CollectionFiles)
.filter(f => f?.get('file') === path)
.get(0);
return file && file.get('name');
},
entryLabel(collection: Collection, slug: string) {
const file = this.fileForEntry(collection, slug);
return file && file.get('label');
},
allowNewEntries() {
return false;
},
allowDeletion(collection: Collection) {
return collection.get('delete', false);
},
templateName(_collection: Collection, slug: string) {
return slug;
},
},
};
function getFieldsWithMediaFolders(fields: EntryField[]) {
const fieldsWithMediaFolders = fields.reduce((acc, f) => {
if (f.has('media_folder')) {
acc = [...acc, f];
}
if (f.has('fields')) {
const fields = f.get('fields')?.toArray() as EntryField[];
acc = [...acc, ...getFieldsWithMediaFolders(fields)];
} else if (f.has('field')) {
const field = f.get('field') as EntryField;
acc = [...acc, ...getFieldsWithMediaFolders([field])];
} else if (f.has('types')) {
const types = f.get('types')?.toArray() as EntryField[];
acc = [...acc, ...getFieldsWithMediaFolders(types)];
}
return acc;
}, [] as EntryField[]);
return fieldsWithMediaFolders;
}
export function getFileFromSlug(collection: Collection, slug: string) {
return collection
.get('files')
?.toArray()
.find(f => f.get('name') === slug);
}
export function selectFieldsWithMediaFolders(collection: Collection, slug: string) {
if (collection.has('folder')) {
const fields = collection.get('fields').toArray();
return getFieldsWithMediaFolders(fields);
} else if (collection.has('files')) {
const fields = getFileFromSlug(collection, slug)?.get('fields').toArray() || [];
return getFieldsWithMediaFolders(fields);
}
return [];
}
export function selectMediaFolders(config: CmsConfig, collection: Collection, entry: EntryMap) {
const fields = selectFieldsWithMediaFolders(collection, entry.get('slug'));
const folders = fields.map(f => selectMediaFolder(config, collection, entry, f));
if (collection.has('files')) {
const file = getFileFromSlug(collection, entry.get('slug'));
if (file) {
folders.unshift(selectMediaFolder(config, collection, entry, undefined));
}
}
if (collection.has('media_folder')) {
// stop evaluating media folders at collection level
collection = collection.delete('files');
folders.unshift(selectMediaFolder(config, collection, entry, undefined));
}
return Set(folders).toArray();
}
export function selectFields(collection: Collection, slug: string) {
return selectors[collection.get('type')].fields(collection, slug);
}
export function selectFolderEntryExtension(collection: Collection) {
return selectors[FOLDER].entryExtension(collection);
}
export function selectFileEntryLabel(collection: Collection, slug: string) {
return selectors[FILES].entryLabel(collection, slug);
}
export function selectEntryPath(collection: Collection, slug: string) {
return selectors[collection.get('type')].entryPath(collection, slug);
}
export function selectEntrySlug(collection: Collection, path: string) {
return selectors[collection.get('type')].entrySlug(collection, path);
}
export function selectAllowNewEntries(collection: Collection) {
return selectors[collection.get('type')].allowNewEntries(collection);
}
export function selectAllowDeletion(collection: Collection) {
return selectors[collection.get('type')].allowDeletion(collection);
}
export function selectTemplateName(collection: Collection, slug: string) {
return selectors[collection.get('type')].templateName(collection, slug);
}
export function getFieldsNames(fields: EntryField[], prefix = '') {
let names = fields.map(f => `${prefix}${f.get('name')}`);
fields.forEach((f, index) => {
if (f.has('fields')) {
const fields = f.get('fields')?.toArray() as EntryField[];
names = [...names, ...getFieldsNames(fields, `${names[index]}.`)];
} else if (f.has('field')) {
const field = f.get('field') as EntryField;
names = [...names, ...getFieldsNames([field], `${names[index]}.`)];
} else if (f.has('types')) {
const types = f.get('types')?.toArray() as EntryField[];
names = [...names, ...getFieldsNames(types, `${names[index]}.`)];
}
});
return names;
}
export function selectField(collection: Collection, key: string) {
const array = keyToPathArray(key);
let name: string | undefined;
let field;
let fields = collection.get('fields', List<EntryField>()).toArray();
while ((name = array.shift()) && fields) {
field = fields.find(f => f.get('name') === name);
if (field?.has('fields')) {
fields = field?.get('fields')?.toArray() as EntryField[];
} else if (field?.has('field')) {
fields = [field?.get('field') as EntryField];
} else if (field?.has('types')) {
fields = field?.get('types')?.toArray() as EntryField[];
}
}
return field;
}
export function traverseFields(
fields: List<EntryField>,
updater: (field: EntryField) => EntryField,
done = () => false,
) {
if (done()) {
return fields;
}
fields = fields
.map(f => {
const field = updater(f as EntryField);
if (done()) {
return field;
} else if (field.has('fields')) {
return field.set('fields', traverseFields(field.get('fields')!, updater, done));
} else if (field.has('field')) {
return field.set(
'field',
traverseFields(List([field.get('field')!]), updater, done).get(0),
);
} else if (field.has('types')) {
return field.set('types', traverseFields(field.get('types')!, updater, done));
} else {
return field;
}
})
.toList() as List<EntryField>;
return fields;
}
export function updateFieldByKey(
collection: Collection,
key: string,
updater: (field: EntryField) => EntryField,
) {
const selected = selectField(collection, key);
if (!selected) {
return collection;
}
let updated = false;
function updateAndBreak(f: EntryField) {
const field = f as EntryField;
if (field === selected) {
updated = true;
return updater(field);
} else {
return field;
}
}
collection = collection.set(
'fields',
traverseFields(collection.get('fields', List<EntryField>()), updateAndBreak, () => updated),
);
return collection;
}
export function selectIdentifier(collection: Collection) {
const identifier = collection.get('identifier_field');
const identifierFields = identifier ? [identifier, ...IDENTIFIER_FIELDS] : [...IDENTIFIER_FIELDS];
const fieldNames = getFieldsNames(collection.get('fields', List()).toArray());
return identifierFields.find(id =>
fieldNames.find(name => name.toLowerCase().trim() === id.toLowerCase().trim()),
);
}
export function selectInferredField(collection: Collection, fieldName: string) {
if (fieldName === 'title' && collection.get('identifier_field')) {
return selectIdentifier(collection);
}
const inferableField = (
INFERABLE_FIELDS as Record<
string,
{
type: string;
synonyms: string[];
secondaryTypes: string[];
fallbackToFirstField: boolean;
showError: boolean;
}
>
)[fieldName];
const fields = collection.get('fields');
let field;
// If collection has no fields or fieldName is not defined within inferables list, return null
if (!fields || !inferableField) return null;
// Try to return a field of the specified type with one of the synonyms
const mainTypeFields = fields
.filter(f => f?.get('widget', 'string') === inferableField.type)
.map(f => f?.get('name'));
field = mainTypeFields.filter(f => inferableField.synonyms.indexOf(f as string) !== -1);
if (field && field.size > 0) return field.first();
// Try to return a field for each of the specified secondary types
const secondaryTypeFields = fields
.filter(f => inferableField.secondaryTypes.indexOf(f?.get('widget', 'string') as string) !== -1)
.map(f => f?.get('name'));
field = secondaryTypeFields.filter(f => inferableField.synonyms.indexOf(f as string) !== -1);
if (field && field.size > 0) return field.first();
// Try to return the first field of the specified type
if (inferableField.fallbackToFirstField && mainTypeFields.size > 0) return mainTypeFields.first();
// Coundn't infer the field. Show error and return null.
if (inferableField.showError) {
consoleError(
`The Field ${fieldName} is missing for the collection “${collection.get('name')}`,
`Decap CMS tries to infer the entry ${fieldName} automatically, but one couldn't be found for entries of the collection “${collection.get(
'name',
)}”. Please check your site configuration.`,
);
}
return null;
}
export function selectEntryCollectionTitle(collection: Collection, entry: EntryMap) {
// prefer formatted summary over everything else
const summaryTemplate = collection.get('summary');
if (summaryTemplate) return summaryFormatter(summaryTemplate, entry, collection);
// if the collection is a file collection return the label of the entry
if (collection.get('type') == FILES) {
const label = selectFileEntryLabel(collection, entry.get('slug'));
if (label) return label;
}
// try to infer a title field from the entry data
const entryData = entry.get('data');
const titleField = selectInferredField(collection, 'title');
const result = titleField && entryData.getIn(keyToPathArray(titleField));
// if the custom field does not yield a result, fallback to 'title'
if (!result && titleField !== 'title') {
return entryData.getIn(keyToPathArray('title'));
}
return result;
}
export function selectDefaultSortableFields(
collection: Collection,
backend: Backend,
hasIntegration: boolean,
) {
let defaultSortable = SORTABLE_FIELDS.map((type: string) => {
const field = selectInferredField(collection, type);
if (backend.isGitBackend() && type === 'author' && !field && !hasIntegration) {
// default to commit author if not author field is found
return COMMIT_AUTHOR;
}
return field;
}).filter(Boolean);
if (backend.isGitBackend() && !hasIntegration) {
// always have commit date by default
defaultSortable = [COMMIT_DATE, ...defaultSortable];
}
return defaultSortable as string[];
}
export function selectSortableFields(collection: Collection, t: (key: string) => string) {
const fields = collection
.get('sortable_fields')
.toArray()
.map(key => {
if (key === COMMIT_DATE) {
return { key, field: { name: key, label: t('collection.defaultFields.updatedOn.label') } };
}
const field = selectField(collection, key);
if (key === COMMIT_AUTHOR && !field) {
return { key, field: { name: key, label: t('collection.defaultFields.author.label') } };
}
return { key, field: field?.toJS() };
})
.filter(item => !!item.field)
.map(item => ({ ...item.field, key: item.key }));
return fields;
}
export function selectSortDataPath(collection: Collection, key: string) {
if (key === COMMIT_DATE) {
return 'updatedOn';
} else if (key === COMMIT_AUTHOR && !selectField(collection, key)) {
return 'author';
} else {
return `data.${key}`;
}
}
export function selectViewFilters(collection: Collection) {
const viewFilters = collection.get('view_filters').toJS() as ViewFilter[];
return viewFilters;
}
export function selectViewGroups(collection: Collection) {
const viewGroups = collection.get('view_groups').toJS() as ViewGroup[];
return viewGroups;
}
export function selectFieldsComments(collection: Collection, entryMap: EntryMap) {
let fields: EntryField[] = [];
if (collection.has('folder')) {
fields = collection.get('fields').toArray();
} else if (collection.has('files')) {
const file = collection.get('files')!.find(f => f?.get('name') === entryMap.get('slug'));
fields = file.get('fields').toArray();
}
const comments: Record<string, string> = {};
const names = getFieldsNames(fields);
names.forEach(name => {
const field = selectField(collection, name);
if (field?.has('comment')) {
comments[name] = field.get('comment')!;
}
});
return comments;
}
export function selectHasMetaPath(collection: Collection) {
return (
collection.has('folder') &&
collection.get('type') === FOLDER &&
collection.has('meta') &&
collection.get('meta')?.has('path')
);
}
export default collections;

View File

@@ -0,0 +1,11 @@
import { combineReducers } from 'redux';
import reducers from './index';
function createRootReducer() {
return combineReducers({
...reducers,
});
}
export default createRootReducer;

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

@@ -0,0 +1,38 @@
import { produce } from 'immer';
import { CONFIG_REQUEST, CONFIG_SUCCESS, CONFIG_FAILURE } from '../actions/config';
import { EDITORIAL_WORKFLOW } from '../constants/publishModes';
import type { ConfigAction } from '../actions/config';
import type { CmsConfig } from '../types/redux';
const defaultState = {
isFetching: true,
};
const config = produce((state: CmsConfig, action: ConfigAction) => {
switch (action.type) {
case CONFIG_REQUEST:
state.isFetching = true;
break;
case CONFIG_SUCCESS:
return {
...action.payload,
isFetching: false,
error: undefined,
};
case CONFIG_FAILURE:
state.isFetching = false;
state.error = action.payload.toString();
}
}, defaultState);
export function selectLocale(state: CmsConfig) {
return state.locale || 'en';
}
export function selectUseWorkflow(state: CmsConfig) {
return state.publish_mode === EDITORIAL_WORKFLOW;
}
export default config;

36
node_modules/decap-cms-core/src/reducers/cursors.js generated vendored Normal file
View File

@@ -0,0 +1,36 @@
import { fromJS } from 'immutable';
import { Cursor } from 'decap-cms-lib-util';
import {
ENTRIES_SUCCESS,
SORT_ENTRIES_SUCCESS,
FILTER_ENTRIES_SUCCESS,
GROUP_ENTRIES_SUCCESS,
} from '../actions/entries';
// Since pagination can be used for a variety of views (collections
// and searches are the most common examples), we namespace cursors by
// their type before storing them in the state.
export function selectCollectionEntriesCursor(state, collectionName) {
return new Cursor(state.getIn(['cursorsByType', 'collectionEntries', collectionName]));
}
function cursors(state = fromJS({ cursorsByType: { collectionEntries: {} } }), action) {
switch (action.type) {
case ENTRIES_SUCCESS: {
return state.setIn(
['cursorsByType', 'collectionEntries', action.payload.collection],
Cursor.create(action.payload.cursor).store,
);
}
case FILTER_ENTRIES_SUCCESS:
case GROUP_ENTRIES_SUCCESS:
case SORT_ENTRIES_SUCCESS: {
return state.deleteIn(['cursorsByType', 'collectionEntries', action.payload.collection]);
}
default:
return state;
}
}
export default cursors;

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

@@ -0,0 +1,52 @@
import { produce } from 'immer';
import {
DEPLOY_PREVIEW_REQUEST,
DEPLOY_PREVIEW_SUCCESS,
DEPLOY_PREVIEW_FAILURE,
} from '../actions/deploys';
import type { DeploysAction } from '../actions/deploys';
export type Deploys = {
[key: string]: {
isFetching: boolean;
url?: string;
status?: string;
};
};
const defaultState: Deploys = {};
const deploys = produce((state: Deploys, action: DeploysAction) => {
switch (action.type) {
case DEPLOY_PREVIEW_REQUEST: {
const { collection, slug } = action.payload;
const key = `${collection}.${slug}`;
state[key] = state[key] || {};
state[key].isFetching = true;
break;
}
case DEPLOY_PREVIEW_SUCCESS: {
const { collection, slug, url, status } = action.payload;
const key = `${collection}.${slug}`;
state[key].isFetching = false;
state[key].url = url;
state[key].status = status;
break;
}
case DEPLOY_PREVIEW_FAILURE: {
const { collection, slug } = action.payload;
state[`${collection}.${slug}`].isFetching = false;
break;
}
}
}, defaultState);
export function selectDeployPreview(state: Deploys, collection: string, slug: string) {
return state[`${collection}.${slug}`];
}
export default deploys;

View File

@@ -0,0 +1,163 @@
import { Map, List, fromJS } from 'immutable';
import { startsWith } from 'lodash';
import { EDITORIAL_WORKFLOW } from '../constants/publishModes';
import {
UNPUBLISHED_ENTRY_REQUEST,
UNPUBLISHED_ENTRY_REDIRECT,
UNPUBLISHED_ENTRY_SUCCESS,
UNPUBLISHED_ENTRIES_REQUEST,
UNPUBLISHED_ENTRIES_SUCCESS,
UNPUBLISHED_ENTRY_PERSIST_REQUEST,
UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
UNPUBLISHED_ENTRY_PERSIST_FAILURE,
UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST,
UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS,
UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE,
UNPUBLISHED_ENTRY_PUBLISH_REQUEST,
UNPUBLISHED_ENTRY_PUBLISH_SUCCESS,
UNPUBLISHED_ENTRY_PUBLISH_FAILURE,
UNPUBLISHED_ENTRY_DELETE_SUCCESS,
} from '../actions/editorialWorkflow';
import { CONFIG_SUCCESS } from '../actions/config';
import type { EditorialWorkflowAction, EditorialWorkflow, Entities } from '../types/redux';
function unpublishedEntries(state = Map(), action: EditorialWorkflowAction) {
switch (action.type) {
case CONFIG_SUCCESS: {
const publishMode = action.payload && action.payload.publish_mode;
if (publishMode === EDITORIAL_WORKFLOW) {
// Editorial workflow state is explicitly initiated after the config.
return Map({ entities: Map(), pages: Map() });
}
return state;
}
case UNPUBLISHED_ENTRY_REQUEST:
return state.setIn(
['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'isFetching'],
true,
);
case UNPUBLISHED_ENTRY_REDIRECT:
return state.deleteIn(['entities', `${action.payload!.collection}.${action.payload!.slug}`]);
case UNPUBLISHED_ENTRY_SUCCESS:
return state.setIn(
['entities', `${action.payload!.collection}.${action.payload!.entry.slug}`],
fromJS(action.payload!.entry),
);
case UNPUBLISHED_ENTRIES_REQUEST:
return state.setIn(['pages', 'isFetching'], true);
case UNPUBLISHED_ENTRIES_SUCCESS:
return state.withMutations(map => {
action.payload!.entries.forEach(entry =>
map.setIn(
['entities', `${entry.collection}.${entry.slug}`],
fromJS(entry).set('isFetching', false),
),
);
map.set(
'pages',
Map({
...action.payload!.pages,
ids: List(action.payload!.entries.map(entry => entry.slug)),
}),
);
});
case UNPUBLISHED_ENTRY_PERSIST_REQUEST: {
return state.setIn(
['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'isPersisting'],
true,
);
}
case UNPUBLISHED_ENTRY_PERSIST_SUCCESS:
// Update Optimistically
return state.withMutations(map => {
map.setIn(
['entities', `${action.payload!.collection}.${action.payload!.entry.get('slug')}`],
fromJS(action.payload!.entry),
);
map.deleteIn([
'entities',
`${action.payload!.collection}.${action.payload!.entry.get('slug')}`,
'isPersisting',
]);
map.updateIn(['pages', 'ids'], List(), list =>
list.push(action.payload!.entry.get('slug')),
);
});
case UNPUBLISHED_ENTRY_PERSIST_FAILURE:
return state.setIn(
['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'isPersisting'],
false,
);
case UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST:
// Update Optimistically
return state.setIn(
['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'isUpdatingStatus'],
true,
);
case UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS:
return state.withMutations(map => {
map.setIn(
['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'status'],
action.payload!.newStatus,
);
map.setIn(
['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'isUpdatingStatus'],
false,
);
});
case UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE:
return state.setIn(
['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'isUpdatingStatus'],
false,
);
case UNPUBLISHED_ENTRY_PUBLISH_REQUEST:
return state.setIn(
['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'isPublishing'],
true,
);
case UNPUBLISHED_ENTRY_PUBLISH_SUCCESS:
return state.deleteIn(['entities', `${action.payload!.collection}.${action.payload!.slug}`]);
case UNPUBLISHED_ENTRY_DELETE_SUCCESS:
return state.deleteIn(['entities', `${action.payload!.collection}.${action.payload!.slug}`]);
case UNPUBLISHED_ENTRY_PUBLISH_FAILURE:
default:
return state;
}
}
export function selectUnpublishedEntry(state: EditorialWorkflow, collection: string, slug: string) {
return state && state.getIn(['entities', `${collection}.${slug}`]);
}
export function selectUnpublishedEntriesByStatus(state: EditorialWorkflow, status: string) {
if (!state) return null;
const entities = state.get('entities') as Entities;
return entities.filter(entry => entry.get('status') === status).valueSeq();
}
export function selectUnpublishedSlugs(state: EditorialWorkflow, collection: string) {
if (!state.get('entities')) return null;
const entities = state.get('entities') as Entities;
return entities
.filter((_v, k) => startsWith(k as string, `${collection}.`))
.map(entry => entry.get('slug'))
.valueSeq();
}
export default unpublishedEntries;

806
node_modules/decap-cms-core/src/reducers/entries.ts generated vendored Normal file
View File

@@ -0,0 +1,806 @@
import { Map, List, fromJS, OrderedMap, Set } from 'immutable';
import { dirname, join } from 'path';
import { isAbsolutePath, basename } from 'decap-cms-lib-util';
import { trim, once, sortBy, set, orderBy, groupBy } from 'lodash';
import { stringTemplate } from 'decap-cms-lib-widgets';
import { SortDirection } from '../types/redux';
import { folderFormatter } from '../lib/formatters';
import { selectSortDataPath } from './collections';
import { SEARCH_ENTRIES_SUCCESS } from '../actions/search';
import {
ENTRY_REQUEST,
ENTRY_SUCCESS,
ENTRY_FAILURE,
ENTRIES_REQUEST,
ENTRIES_SUCCESS,
ENTRIES_FAILURE,
ENTRY_DELETE_SUCCESS,
SORT_ENTRIES_REQUEST,
SORT_ENTRIES_SUCCESS,
SORT_ENTRIES_FAILURE,
FILTER_ENTRIES_REQUEST,
FILTER_ENTRIES_SUCCESS,
FILTER_ENTRIES_FAILURE,
GROUP_ENTRIES_REQUEST,
GROUP_ENTRIES_SUCCESS,
GROUP_ENTRIES_FAILURE,
CHANGE_VIEW_STYLE,
} from '../actions/entries';
import { VIEW_STYLE_LIST } from '../constants/collectionViews';
import { joinUrlPath } from '../lib/urlHelper';
import type {
EntriesAction,
EntryRequestPayload,
EntrySuccessPayload,
EntriesSuccessPayload,
EntryObject,
Entries,
CmsConfig,
Collection,
EntryFailurePayload,
EntryDeletePayload,
EntriesRequestPayload,
EntryDraft,
EntryMap,
EntryField,
CollectionFiles,
EntriesSortRequestPayload,
EntriesSortFailurePayload,
SortMap,
SortObject,
Sort,
Filter,
Group,
FilterMap,
GroupMap,
EntriesFilterRequestPayload,
EntriesFilterFailurePayload,
ChangeViewStylePayload,
EntriesGroupRequestPayload,
EntriesGroupFailurePayload,
GroupOfEntries,
} from '../types/redux';
const { keyToPathArray } = stringTemplate;
let collection: string;
let loadedEntries: EntryObject[];
let append: boolean;
let page: number;
let slug: string;
const storageSortKey = 'decap-cms.entries.sort';
const viewStyleKey = 'decap-cms.entries.viewStyle';
type StorageSortObject = SortObject & { index: number };
type StorageSort = { [collection: string]: { [key: string]: StorageSortObject } };
const loadSort = once(() => {
const sortString = localStorage.getItem(storageSortKey);
if (sortString) {
try {
const sort: StorageSort = JSON.parse(sortString);
let map = Map() as Sort;
Object.entries(sort).forEach(([collection, sort]) => {
let orderedMap = OrderedMap() as SortMap;
sortBy(Object.values(sort), ['index']).forEach(value => {
const { key, direction } = value;
orderedMap = orderedMap.set(key, fromJS({ key, direction }));
});
map = map.set(collection, orderedMap);
});
return map;
} catch (e) {
return Map() as Sort;
}
}
return Map() as Sort;
});
function clearSort() {
localStorage.removeItem(storageSortKey);
}
function persistSort(sort: Sort | undefined) {
if (sort) {
const storageSort: StorageSort = {};
sort.keySeq().forEach(key => {
const collection = key as string;
const sortObjects = (sort.get(collection).valueSeq().toJS() as SortObject[]).map(
(value, index) => ({ ...value, index }),
);
sortObjects.forEach(value => {
set(storageSort, [collection, value.key], value);
});
});
localStorage.setItem(storageSortKey, JSON.stringify(storageSort));
} else {
clearSort();
}
}
const loadViewStyle = once(() => {
const viewStyle = localStorage.getItem(viewStyleKey);
if (viewStyle) {
return viewStyle;
}
localStorage.setItem(viewStyleKey, VIEW_STYLE_LIST);
return VIEW_STYLE_LIST;
});
function clearViewStyle() {
localStorage.removeItem(viewStyleKey);
}
function persistViewStyle(viewStyle: string | undefined) {
if (viewStyle) {
localStorage.setItem(viewStyleKey, viewStyle);
} else {
clearViewStyle();
}
}
function entries(
state = Map({ entities: Map(), pages: Map(), sort: loadSort(), viewStyle: loadViewStyle() }),
action: EntriesAction,
) {
switch (action.type) {
case ENTRY_REQUEST: {
const payload = action.payload as EntryRequestPayload;
return state.setIn(['entities', `${payload.collection}.${payload.slug}`, 'isFetching'], true);
}
case ENTRY_SUCCESS: {
const payload = action.payload as EntrySuccessPayload;
collection = payload.collection;
slug = payload.entry.slug;
return state.withMutations(map => {
map.setIn(['entities', `${collection}.${slug}`], fromJS(payload.entry));
const ids = map.getIn(['pages', collection, 'ids'], List());
if (!ids.includes(slug)) {
map.setIn(['pages', collection, 'ids'], ids.unshift(slug));
}
});
}
case ENTRIES_REQUEST: {
const payload = action.payload as EntriesRequestPayload;
const newState = state.withMutations(map => {
map.setIn(['pages', payload.collection, 'isFetching'], true);
});
return newState;
}
case ENTRIES_SUCCESS: {
const payload = action.payload as EntriesSuccessPayload;
collection = payload.collection;
loadedEntries = payload.entries;
append = payload.append;
page = payload.page;
return state.withMutations(map => {
loadedEntries.forEach(entry =>
map.setIn(
['entities', `${collection}.${entry.slug}`],
fromJS(entry).set('isFetching', false),
),
);
const ids = List(loadedEntries.map(entry => entry.slug));
map.setIn(
['pages', collection],
Map({
page,
ids: append ? map.getIn(['pages', collection, 'ids'], List()).concat(ids) : ids,
}),
);
});
}
case ENTRIES_FAILURE:
return state.setIn(['pages', action.meta.collection, 'isFetching'], false);
case ENTRY_FAILURE: {
const payload = action.payload as EntryFailurePayload;
return state.withMutations(map => {
map.setIn(['entities', `${payload.collection}.${payload.slug}`, 'isFetching'], false);
map.setIn(
['entities', `${payload.collection}.${payload.slug}`, 'error'],
payload.error.message,
);
});
}
case SEARCH_ENTRIES_SUCCESS: {
const payload = action.payload as EntriesSuccessPayload;
loadedEntries = payload.entries;
return state.withMutations(map => {
loadedEntries.forEach(entry =>
map.setIn(
['entities', `${entry.collection}.${entry.slug}`],
fromJS(entry).set('isFetching', false),
),
);
});
}
case ENTRY_DELETE_SUCCESS: {
const payload = action.payload as EntryDeletePayload;
return state.withMutations(map => {
map.deleteIn(['entities', `${payload.collectionName}.${payload.entrySlug}`]);
map.updateIn(['pages', payload.collectionName, 'ids'], (ids: string[]) =>
ids.filter(id => id !== payload.entrySlug),
);
});
}
case SORT_ENTRIES_REQUEST: {
const payload = action.payload as EntriesSortRequestPayload;
const { collection, key, direction } = payload;
const newState = state.withMutations(map => {
const sort = OrderedMap({ [key]: Map({ key, direction }) });
map.setIn(['sort', collection], sort);
map.setIn(['pages', collection, 'isFetching'], true);
map.deleteIn(['pages', collection, 'page']);
});
persistSort(newState.get('sort') as Sort);
return newState;
}
case GROUP_ENTRIES_SUCCESS:
case FILTER_ENTRIES_SUCCESS:
case SORT_ENTRIES_SUCCESS: {
const payload = action.payload as { collection: string; entries: EntryObject[] };
const { collection, entries } = payload;
loadedEntries = entries;
const newState = state.withMutations(map => {
loadedEntries.forEach(entry =>
map.setIn(
['entities', `${entry.collection}.${entry.slug}`],
fromJS(entry).set('isFetching', false),
),
);
map.setIn(['pages', collection, 'isFetching'], false);
const ids = List(loadedEntries.map(entry => entry.slug));
map.setIn(
['pages', collection],
Map({
page: 1,
ids,
}),
);
});
return newState;
}
case SORT_ENTRIES_FAILURE: {
const payload = action.payload as EntriesSortFailurePayload;
const { collection, key } = payload;
const newState = state.withMutations(map => {
map.deleteIn(['sort', collection, key]);
map.setIn(['pages', collection, 'isFetching'], false);
});
persistSort(newState.get('sort') as Sort);
return newState;
}
case FILTER_ENTRIES_REQUEST: {
const payload = action.payload as EntriesFilterRequestPayload;
const { collection, filter } = payload;
const newState = state.withMutations(map => {
const current: FilterMap = map.getIn(['filter', collection, filter.id], fromJS(filter));
map.setIn(
['filter', collection, current.get('id')],
current.set('active', !current.get('active')),
);
});
return newState;
}
case FILTER_ENTRIES_FAILURE: {
const payload = action.payload as EntriesFilterFailurePayload;
const { collection, filter } = payload;
const newState = state.withMutations(map => {
map.deleteIn(['filter', collection, filter.id]);
map.setIn(['pages', collection, 'isFetching'], false);
});
return newState;
}
case GROUP_ENTRIES_REQUEST: {
const payload = action.payload as EntriesGroupRequestPayload;
const { collection, group } = payload;
const newState = state.withMutations(map => {
const current: GroupMap = map.getIn(['group', collection, group.id], fromJS(group));
map.deleteIn(['group', collection]);
map.setIn(
['group', collection, current.get('id')],
current.set('active', !current.get('active')),
);
});
return newState;
}
case GROUP_ENTRIES_FAILURE: {
const payload = action.payload as EntriesGroupFailurePayload;
const { collection, group } = payload;
const newState = state.withMutations(map => {
map.deleteIn(['group', collection, group.id]);
map.setIn(['pages', collection, 'isFetching'], false);
});
return newState;
}
case CHANGE_VIEW_STYLE: {
const payload = action.payload as unknown as ChangeViewStylePayload;
const { style } = payload;
const newState = state.withMutations(map => {
map.setIn(['viewStyle'], style);
});
persistViewStyle(newState.get('viewStyle') as string);
return newState;
}
default:
return state;
}
}
export function selectEntriesSort(entries: Entries, collection: string) {
const sort = entries.get('sort') as Sort | undefined;
return sort?.get(collection);
}
export function selectEntriesFilter(entries: Entries, collection: string) {
const filter = entries.get('filter') as Filter | undefined;
return filter?.get(collection) || Map();
}
export function selectEntriesGroup(entries: Entries, collection: string) {
const group = entries.get('group') as Group | undefined;
return group?.get(collection) || Map();
}
export function selectEntriesGroupField(entries: Entries, collection: string) {
const groups = selectEntriesGroup(entries, collection);
const value = groups?.valueSeq().find(v => v?.get('active') === true);
return value;
}
export function selectEntriesSortFields(entries: Entries, collection: string) {
const sort = selectEntriesSort(entries, collection);
const values =
sort
?.valueSeq()
.filter(v => v?.get('direction') !== SortDirection.None)
.toArray() || [];
return values;
}
export function selectEntriesFilterFields(entries: Entries, collection: string) {
const filter = selectEntriesFilter(entries, collection);
const values =
filter
?.valueSeq()
.filter(v => v?.get('active') === true)
.toArray() || [];
return values;
}
export function selectViewStyle(entries: Entries) {
return entries.get('viewStyle');
}
export function selectEntry(state: Entries, collection: string, slug: string) {
return state.getIn(['entities', `${collection}.${slug}`]);
}
export function selectPublishedSlugs(state: Entries, collection: string) {
return state.getIn(['pages', collection, 'ids'], List<string>());
}
function getPublishedEntries(state: Entries, collectionName: string) {
const slugs = selectPublishedSlugs(state, collectionName);
const entries =
slugs &&
(slugs.map(slug => selectEntry(state, collectionName, slug as string)) as List<EntryMap>);
return entries;
}
export function selectEntries(state: Entries, collection: Collection) {
const collectionName = collection.get('name');
let entries = getPublishedEntries(state, collectionName);
const sortFields = selectEntriesSortFields(state, collectionName);
if (sortFields && sortFields.length > 0) {
const keys = sortFields.map(v => selectSortDataPath(collection, v.get('key')));
const orders = sortFields.map(v =>
v.get('direction') === SortDirection.Ascending ? 'asc' : 'desc',
);
entries = fromJS(orderBy(entries.toJS(), keys, orders));
}
const filters = selectEntriesFilterFields(state, collectionName);
if (filters && filters.length > 0) {
entries = entries
.filter(e => {
const allMatched = filters.every(f => {
const pattern = f.get('pattern');
const field = f.get('field');
const data = e!.get('data') || Map();
const toMatch = data.getIn(keyToPathArray(field));
const matched =
toMatch !== undefined && new RegExp(String(pattern)).test(String(toMatch));
return matched;
});
return allMatched;
})
.toList();
}
return entries;
}
function getGroup(entry: EntryMap, selectedGroup: GroupMap) {
const label = selectedGroup.get('label');
const field = selectedGroup.get('field');
const fieldData = entry.getIn(['data', ...keyToPathArray(field)]);
if (fieldData === undefined) {
return {
id: 'missing_value',
label,
value: fieldData,
};
}
const dataAsString = String(fieldData);
if (selectedGroup.has('pattern')) {
const pattern = selectedGroup.get('pattern');
let value = '';
try {
const regex = new RegExp(pattern);
const matched = dataAsString.match(regex);
if (matched) {
value = matched[0];
}
} catch (e) {
console.warn(`Invalid view group pattern '${pattern}' for field '${field}'`, e);
}
return {
id: `${label}${value}`,
label,
value,
};
}
return {
id: `${label}${fieldData}`,
label,
value: typeof fieldData === 'boolean' ? fieldData : dataAsString,
};
}
export function selectGroups(state: Entries, collection: Collection) {
const collectionName = collection.get('name');
const entries = getPublishedEntries(state, collectionName);
const selectedGroup = selectEntriesGroupField(state, collectionName);
if (selectedGroup === undefined) {
return [];
}
let groups: Record<string, { id: string; label: string; value: string | boolean | undefined }> =
{};
const groupedEntries = groupBy(entries.toArray(), entry => {
const group = getGroup(entry, selectedGroup);
groups = { ...groups, [group.id]: group };
return group.id;
});
const groupsArray: GroupOfEntries[] = Object.entries(groupedEntries).map(([id, entries]) => {
return {
...groups[id],
paths: Set(entries.map(entry => entry.get('path'))),
};
});
return groupsArray;
}
export function selectEntryByPath(state: Entries, collection: string, path: string) {
const slugs = selectPublishedSlugs(state, collection);
const entries =
slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as List<EntryMap>);
return entries && entries.find(e => e?.get('path') === path);
}
export function selectEntriesLoaded(state: Entries, collection: string) {
return !!state.getIn(['pages', collection]);
}
export function selectIsFetching(state: Entries, collection: string) {
return state.getIn(['pages', collection, 'isFetching'], false);
}
const DRAFT_MEDIA_FILES = 'DRAFT_MEDIA_FILES';
function getFileField(collectionFiles: CollectionFiles, slug: string | undefined) {
const file = collectionFiles.find(f => f?.get('name') === slug);
return file;
}
function hasCustomFolder(
folderKey: 'media_folder' | 'public_folder',
collection: Collection | null,
slug: string | undefined,
field: EntryField | undefined,
) {
if (!collection) {
return false;
}
if (field && field.has(folderKey)) {
return true;
}
if (collection.has('files')) {
const file = getFileField(collection.get('files')!, slug);
if (file && file.has(folderKey)) {
return true;
}
}
if (collection.has(folderKey)) {
return true;
}
return false;
}
function traverseFields(
folderKey: 'media_folder' | 'public_folder',
config: CmsConfig,
collection: Collection,
entryMap: EntryMap | undefined,
field: EntryField,
fields: EntryField[],
currentFolder: string,
): string | null {
const matchedField = fields.filter(f => f === field)[0];
if (matchedField) {
return folderFormatter(
matchedField.has(folderKey) ? matchedField.get(folderKey)! : `{{${folderKey}}}`,
entryMap,
collection,
currentFolder,
folderKey,
config.slug,
);
}
for (let f of fields) {
if (!f.has(folderKey)) {
// add identity template if doesn't exist
f = f.set(folderKey, `{{${folderKey}}}`);
}
const folder = folderFormatter(
f.get(folderKey)!,
entryMap,
collection,
currentFolder,
folderKey,
config.slug,
);
let fieldFolder = null;
if (f.has('fields')) {
fieldFolder = traverseFields(
folderKey,
config,
collection,
entryMap,
field,
f.get('fields')!.toArray(),
folder,
);
} else if (f.has('field')) {
fieldFolder = traverseFields(
folderKey,
config,
collection,
entryMap,
field,
[f.get('field')!],
folder,
);
} else if (f.has('types')) {
fieldFolder = traverseFields(
folderKey,
config,
collection,
entryMap,
field,
f.get('types')!.toArray(),
folder,
);
}
if (fieldFolder != null) {
return fieldFolder;
}
}
return null;
}
function evaluateFolder(
folderKey: 'media_folder' | 'public_folder',
config: CmsConfig,
collection: Collection,
entryMap: EntryMap | undefined,
field: EntryField | undefined,
) {
let currentFolder = config[folderKey]!;
// add identity template if doesn't exist
if (!collection.has(folderKey)) {
collection = collection.set(folderKey, `{{${folderKey}}}`);
}
if (collection.has('files')) {
// files collection evaluate the collection template
// then move on to the specific file configuration denoted by the slug
currentFolder = folderFormatter(
collection.get(folderKey)!,
entryMap,
collection,
currentFolder,
folderKey,
config.slug,
);
let file = getFileField(collection.get('files')!, entryMap?.get('slug'));
if (file) {
if (!file.has(folderKey)) {
// add identity template if doesn't exist
file = file.set(folderKey, `{{${folderKey}}}`);
}
// evaluate the file template and keep evaluating until we match our field
currentFolder = folderFormatter(
file.get(folderKey)!,
entryMap,
collection,
currentFolder,
folderKey,
config.slug,
);
if (field) {
const fieldFolder = traverseFields(
folderKey,
config,
collection,
entryMap,
field,
file.get('fields')!.toArray(),
currentFolder,
);
if (fieldFolder !== null) {
currentFolder = fieldFolder;
}
}
}
} else {
// folder collection, evaluate the collection template
// and keep evaluating until we match our field
currentFolder = folderFormatter(
collection.get(folderKey)!,
entryMap,
collection,
currentFolder,
folderKey,
config.slug,
);
if (field) {
const fieldFolder = traverseFields(
folderKey,
config,
collection,
entryMap,
field,
collection.get('fields')!.toArray(),
currentFolder,
);
if (fieldFolder !== null) {
currentFolder = fieldFolder;
}
}
}
return currentFolder;
}
export function selectMediaFolder(
config: CmsConfig,
collection: Collection | null,
entryMap: EntryMap | undefined,
field: EntryField | undefined,
) {
const name = 'media_folder';
let mediaFolder = config[name];
const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field);
if (customFolder) {
const folder = evaluateFolder(name, config, collection!, entryMap, field);
if (folder.startsWith('/')) {
// return absolute paths as is
mediaFolder = join(folder);
} else {
const entryPath = entryMap?.get('path');
mediaFolder = entryPath
? join(dirname(entryPath), folder)
: join(collection!.get('folder') as string, DRAFT_MEDIA_FILES);
}
}
return trim(mediaFolder, '/');
}
export function selectMediaFilePath(
config: CmsConfig,
collection: Collection | null,
entryMap: EntryMap | undefined,
mediaPath: string,
field: EntryField | undefined,
) {
if (isAbsolutePath(mediaPath)) {
return mediaPath;
}
const mediaFolder = selectMediaFolder(config, collection, entryMap, field);
return join(mediaFolder, basename(mediaPath));
}
export function selectMediaFilePublicPath(
config: CmsConfig,
collection: Collection | null,
mediaPath: string,
entryMap: EntryMap | undefined,
field: EntryField | undefined,
) {
if (isAbsolutePath(mediaPath)) {
return mediaPath;
}
const name = 'public_folder';
let publicFolder = config[name]!;
const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field);
if (customFolder) {
publicFolder = evaluateFolder(name, config, collection!, entryMap, field);
}
if (isAbsolutePath(publicFolder)) {
return joinUrlPath(publicFolder, basename(mediaPath));
}
return join(publicFolder, basename(mediaPath));
}
export function selectEditingDraft(state: EntryDraft) {
const entry = state.get('entry');
const workflowDraft = entry && !entry.isEmpty();
return workflowDraft;
}
export default entries;

218
node_modules/decap-cms-core/src/reducers/entryDraft.js generated vendored Normal file
View File

@@ -0,0 +1,218 @@
import { Map, List, fromJS } from 'immutable';
import { v4 as uuid } from 'uuid';
import { get } from 'lodash';
import { join } from 'path';
import {
DRAFT_CREATE_FROM_ENTRY,
DRAFT_CREATE_EMPTY,
DRAFT_DISCARD,
DRAFT_CHANGE_FIELD,
DRAFT_VALIDATION_ERRORS,
DRAFT_CLEAR_ERRORS,
DRAFT_LOCAL_BACKUP_RETRIEVED,
DRAFT_CREATE_FROM_LOCAL_BACKUP,
DRAFT_CREATE_DUPLICATE_FROM_ENTRY,
ENTRY_PERSIST_REQUEST,
ENTRY_PERSIST_SUCCESS,
ENTRY_PERSIST_FAILURE,
ENTRY_DELETE_SUCCESS,
ADD_DRAFT_ENTRY_MEDIA_FILE,
REMOVE_DRAFT_ENTRY_MEDIA_FILE,
} from '../actions/entries';
import {
UNPUBLISHED_ENTRY_PERSIST_REQUEST,
UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
UNPUBLISHED_ENTRY_PERSIST_FAILURE,
UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST,
UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS,
UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE,
UNPUBLISHED_ENTRY_PUBLISH_REQUEST,
UNPUBLISHED_ENTRY_PUBLISH_SUCCESS,
UNPUBLISHED_ENTRY_PUBLISH_FAILURE,
} from '../actions/editorialWorkflow';
import { selectFolderEntryExtension, selectHasMetaPath } from './collections';
import { getDataPath, duplicateI18nFields } from '../lib/i18n';
const initialState = Map({
entry: Map(),
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
key: '',
});
function entryDraftReducer(state = Map(), action) {
switch (action.type) {
case DRAFT_CREATE_FROM_ENTRY:
// Existing Entry
return state.withMutations(state => {
state.set('entry', fromJS(action.payload.entry));
state.setIn(['entry', 'newRecord'], false);
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', false);
state.set('key', uuid());
});
case DRAFT_CREATE_EMPTY:
// New Entry
return state.withMutations(state => {
state.set('entry', fromJS(action.payload));
state.setIn(['entry', 'newRecord'], true);
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', false);
state.set('key', uuid());
});
case DRAFT_CREATE_FROM_LOCAL_BACKUP:
// Local Backup
return state.withMutations(state => {
const backupDraftEntry = state.get('localBackup');
const backupEntry = backupDraftEntry.get('entry');
state.delete('localBackup');
state.set('entry', backupEntry);
state.setIn(['entry', 'newRecord'], !backupEntry.get('path'));
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', true);
state.set('key', uuid());
});
case DRAFT_CREATE_DUPLICATE_FROM_ENTRY:
// Duplicate Entry
return state.withMutations(state => {
state.set('entry', fromJS(action.payload));
state.setIn(['entry', 'newRecord'], true);
state.set('mediaFiles', List());
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', true);
});
case DRAFT_DISCARD:
return initialState;
case DRAFT_LOCAL_BACKUP_RETRIEVED: {
const { entry } = action.payload;
const newState = new Map({
entry: fromJS(entry),
});
return state.set('localBackup', newState);
}
case DRAFT_CHANGE_FIELD: {
return state.withMutations(state => {
const { field, value, metadata, entries, i18n } = action.payload;
const name = field.get('name');
const meta = field.get('meta');
const dataPath = (i18n && getDataPath(i18n.currentLocale, i18n.defaultLocale)) || ['data'];
if (meta) {
state.setIn(['entry', 'meta', name], value);
} else {
state.setIn(['entry', ...dataPath, name], value);
if (i18n) {
state = duplicateI18nFields(state, field, i18n.locales, i18n.defaultLocale);
}
}
state.mergeDeepIn(['fieldsMetaData'], fromJS(metadata));
const newData = state.getIn(['entry', ...dataPath]);
const newMeta = state.getIn(['entry', 'meta']);
state.set(
'hasChanged',
!entries.some(e => newData.equals(e.get(...dataPath))) ||
!entries.some(e => newMeta.equals(e.get('meta'))),
);
});
}
case DRAFT_VALIDATION_ERRORS:
if (action.payload.errors.length === 0) {
return state.deleteIn(['fieldsErrors', action.payload.uniquefieldId]);
} else {
return state.setIn(['fieldsErrors', action.payload.uniquefieldId], action.payload.errors);
}
case DRAFT_CLEAR_ERRORS: {
return state.set('fieldsErrors', Map());
}
case ENTRY_PERSIST_REQUEST:
case UNPUBLISHED_ENTRY_PERSIST_REQUEST: {
return state.setIn(['entry', 'isPersisting'], true);
}
case ENTRY_PERSIST_FAILURE:
case UNPUBLISHED_ENTRY_PERSIST_FAILURE: {
return state.deleteIn(['entry', 'isPersisting']);
}
case UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST:
return state.setIn(['entry', 'isUpdatingStatus'], true);
case UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE:
case UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS:
return state.deleteIn(['entry', 'isUpdatingStatus']);
case UNPUBLISHED_ENTRY_PUBLISH_REQUEST:
return state.setIn(['entry', 'isPublishing'], true);
case UNPUBLISHED_ENTRY_PUBLISH_SUCCESS:
case UNPUBLISHED_ENTRY_PUBLISH_FAILURE:
return state.deleteIn(['entry', 'isPublishing']);
case ENTRY_PERSIST_SUCCESS:
case UNPUBLISHED_ENTRY_PERSIST_SUCCESS:
return state.withMutations(state => {
state.deleteIn(['entry', 'isPersisting']);
state.set('hasChanged', false);
if (!state.getIn(['entry', 'slug'])) {
state.setIn(['entry', 'slug'], action.payload.slug);
}
});
case ENTRY_DELETE_SUCCESS:
return state.withMutations(state => {
state.deleteIn(['entry', 'isPersisting']);
state.set('hasChanged', false);
});
case ADD_DRAFT_ENTRY_MEDIA_FILE: {
return state.withMutations(state => {
const mediaFiles = state.getIn(['entry', 'mediaFiles']);
state.setIn(
['entry', 'mediaFiles'],
mediaFiles
.filterNot(file => file.get('id') === action.payload.id)
.insert(0, fromJS(action.payload)),
);
state.set('hasChanged', true);
});
}
case REMOVE_DRAFT_ENTRY_MEDIA_FILE: {
return state.withMutations(state => {
const mediaFiles = state.getIn(['entry', 'mediaFiles']);
state.setIn(
['entry', 'mediaFiles'],
mediaFiles.filterNot(file => file.get('id') === action.payload.id),
);
state.set('hasChanged', true);
});
}
default:
return state;
}
}
export function selectCustomPath(collection, entryDraft) {
if (!selectHasMetaPath(collection)) {
return;
}
const meta = entryDraft.getIn(['entry', 'meta']);
const path = meta && meta.get('path');
const indexFile = get(collection.toJS(), ['meta', 'path', 'index_file']);
const extension = selectFolderEntryExtension(collection);
const customPath = path && join(collection.get('folder'), path, `${indexFile}.${extension}`);
return customPath;
}
export default entryDraftReducer;

45
node_modules/decap-cms-core/src/reducers/globalUI.ts generated vendored Normal file
View File

@@ -0,0 +1,45 @@
import { produce } from 'immer';
import { USE_OPEN_AUTHORING } from '../actions/auth';
import type { AnyAction } from 'redux';
export type GlobalUI = {
isFetching: boolean;
useOpenAuthoring: boolean;
};
const LOADING_IGNORE_LIST = [
'DEPLOY_PREVIEW',
'STATUS_REQUEST',
'STATUS_SUCCESS',
'STATUS_FAILURE',
];
function ignoreWhenLoading(action: AnyAction) {
return LOADING_IGNORE_LIST.some(type => action.type.includes(type));
}
const defaultState: GlobalUI = {
isFetching: false,
useOpenAuthoring: false,
};
/**
* Reducer for some global UI state that we want to share between components
*/
const globalUI = produce((state: GlobalUI, action: AnyAction) => {
// Generic, global loading indicator
if (!ignoreWhenLoading(action) && action.type.includes('REQUEST')) {
state.isFetching = true;
} else if (
!ignoreWhenLoading(action) &&
(action.type.includes('SUCCESS') || action.type.includes('FAILURE'))
) {
state.isFetching = false;
} else if (action.type === USE_OPEN_AUTHORING) {
state.useOpenAuthoring = true;
}
}, defaultState);
export default globalUI;

82
node_modules/decap-cms-core/src/reducers/index.ts generated vendored Normal file
View File

@@ -0,0 +1,82 @@
import { List } from 'immutable';
import auth from './auth';
import config from './config';
import integrations, * as fromIntegrations from './integrations';
import entries, * as fromEntries from './entries';
import cursors from './cursors';
import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow';
import entryDraft from './entryDraft';
import collections from './collections';
import search from './search';
import medias from './medias';
import mediaLibrary from './mediaLibrary';
import deploys, * as fromDeploys from './deploys';
import globalUI from './globalUI';
import status from './status';
import notifications from './notifications';
import type { Status } from '../constants/publishModes';
import type { State, Collection } from '../types/redux';
const reducers = {
auth,
config,
collections,
search,
integrations,
entries,
cursors,
editorialWorkflow,
entryDraft,
medias,
mediaLibrary,
deploys,
globalUI,
status,
notifications,
};
export default reducers;
/*
* Selectors
*/
export function selectEntry(state: State, collection: string, slug: string) {
return fromEntries.selectEntry(state.entries, collection, slug);
}
export function selectEntries(state: State, collection: Collection) {
return fromEntries.selectEntries(state.entries, collection);
}
export function selectPublishedSlugs(state: State, collection: string) {
return fromEntries.selectPublishedSlugs(state.entries, collection);
}
export function selectSearchedEntries(state: State, availableCollections: string[]) {
// only return search results for actually available collections
return List(state.search.entryIds)
.filter(entryId => availableCollections.indexOf(entryId!.collection) !== -1)
.map(entryId => fromEntries.selectEntry(state.entries, entryId!.collection, entryId!.slug));
}
export function selectDeployPreview(state: State, collection: string, slug: string) {
return fromDeploys.selectDeployPreview(state.deploys, collection, slug);
}
export function selectUnpublishedEntry(state: State, collection: string, slug: string) {
return fromEditorialWorkflow.selectUnpublishedEntry(state.editorialWorkflow, collection, slug);
}
export function selectUnpublishedEntriesByStatus(state: State, status: Status) {
return fromEditorialWorkflow.selectUnpublishedEntriesByStatus(state.editorialWorkflow, status);
}
export function selectUnpublishedSlugs(state: State, collection: string) {
return fromEditorialWorkflow.selectUnpublishedSlugs(state.editorialWorkflow, collection);
}
export function selectIntegration(state: State, collection: string | null, hook: string) {
return fromIntegrations.selectIntegration(state.integrations, collection, hook);
}

View File

@@ -0,0 +1,59 @@
import { fromJS } from 'immutable';
import { CONFIG_SUCCESS } from '../actions/config';
import type { ConfigAction } from '../actions/config';
import type { Integrations, CmsConfig } from '../types/redux';
interface Acc {
providers: Record<string, {}>;
hooks: Record<string, string | Record<string, string>>;
}
export function getIntegrations(config: CmsConfig) {
const integrations = config.integrations || [];
const newState = integrations.reduce(
(acc, integration) => {
const { hooks, collections, provider, ...providerData } = integration;
acc.providers[provider] = { ...providerData };
if (!collections) {
hooks.forEach(hook => {
acc.hooks[hook] = provider;
});
return acc;
}
const integrationCollections =
collections === '*' ? config.collections.map(collection => collection.name) : collections;
integrationCollections.forEach(collection => {
hooks.forEach(hook => {
acc.hooks[collection]
? ((acc.hooks[collection] as Record<string, string>)[hook] = provider)
: (acc.hooks[collection] = { [hook]: provider });
});
});
return acc;
},
{ providers: {}, hooks: {} } as Acc,
);
return fromJS(newState);
}
const defaultState = fromJS({ providers: {}, hooks: {} });
function integrations(state = defaultState, action: ConfigAction): Integrations | null {
switch (action.type) {
case CONFIG_SUCCESS: {
return getIntegrations(action.payload);
}
default:
return state;
}
}
export function selectIntegration(state: Integrations, collection: string | null, hook: string) {
return collection
? state.getIn(['hooks', collection, hook], false)
: state.getIn(['hooks', hook], false);
}
export default integrations;

View File

@@ -0,0 +1,296 @@
import { Map, List } from 'immutable';
import { v4 as uuid } from 'uuid';
import { dirname } from 'path';
import {
MEDIA_LIBRARY_OPEN,
MEDIA_LIBRARY_CLOSE,
MEDIA_LIBRARY_CREATE,
MEDIA_INSERT,
MEDIA_REMOVE_INSERTED,
MEDIA_LOAD_REQUEST,
MEDIA_LOAD_SUCCESS,
MEDIA_LOAD_FAILURE,
MEDIA_PERSIST_REQUEST,
MEDIA_PERSIST_SUCCESS,
MEDIA_PERSIST_FAILURE,
MEDIA_DELETE_REQUEST,
MEDIA_DELETE_SUCCESS,
MEDIA_DELETE_FAILURE,
MEDIA_DISPLAY_URL_REQUEST,
MEDIA_DISPLAY_URL_SUCCESS,
MEDIA_DISPLAY_URL_FAILURE,
} from '../actions/mediaLibrary';
import { selectEditingDraft, selectMediaFolder } from './entries';
import { selectIntegration } from './';
import type { MediaLibraryAction } from '../actions/mediaLibrary';
import type {
State,
MediaLibraryInstance,
MediaFile,
MediaFileMap,
DisplayURLState,
EntryField,
} from '../types/redux';
const defaultState: {
isVisible: boolean;
showMediaButton: boolean;
controlMedia: Map<string, string>;
displayURLs: Map<string, string>;
externalLibrary?: MediaLibraryInstance;
controlID?: string;
page?: number;
files?: MediaFile[];
config: Map<string, unknown>;
field?: EntryField;
value?: string | string[];
replaceIndex?: number | boolean;
} = {
isVisible: false,
showMediaButton: true,
controlMedia: Map(),
displayURLs: Map(),
config: Map(),
};
function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) {
switch (action.type) {
case MEDIA_LIBRARY_CREATE:
return state.withMutations(map => {
map.set('externalLibrary', action.payload);
map.set('showMediaButton', action.payload.enableStandalone());
});
case MEDIA_LIBRARY_OPEN: {
const { controlID, forImage, privateUpload, config, field, value, replaceIndex } =
action.payload;
const libConfig = config || Map();
const privateUploadChanged = state.get('privateUpload') !== privateUpload;
if (privateUploadChanged) {
return Map({
isVisible: true,
forImage,
controlID,
canInsert: !!controlID,
privateUpload,
config: libConfig,
controlMedia: Map(),
displayURLs: Map(),
field,
value,
replaceIndex,
});
}
return state.withMutations(map => {
map.set('isVisible', true);
map.set('forImage', forImage ?? false);
map.set('controlID', controlID ?? '');
map.set('canInsert', !!controlID);
map.set('privateUpload', privateUpload);
map.set('config', libConfig);
map.set('field', field ?? '');
map.set('value', value == '' && libConfig.get('multiple') ? [] : value ?? '');
map.set('replaceIndex', replaceIndex ?? false);
});
}
case MEDIA_LIBRARY_CLOSE:
return state.set('isVisible', false);
case MEDIA_INSERT: {
const { mediaPath } = action.payload;
const controlID = state.get('controlID');
const value = state.get('value');
if (!Array.isArray(value)) {
return state.withMutations(map => {
map.setIn(['controlMedia', controlID], mediaPath);
});
}
const replaceIndex = state.get('replaceIndex');
const mediaArray = Array.isArray(mediaPath) ? mediaPath : [mediaPath];
const valueArray = value as string[];
if (typeof replaceIndex == 'number') {
valueArray[replaceIndex] = mediaArray[0];
} else {
valueArray.push(...mediaArray);
}
return state.withMutations(map => {
map.setIn(['controlMedia', controlID], valueArray);
});
}
case MEDIA_REMOVE_INSERTED: {
const controlID = action.payload.controlID;
return state.setIn(['controlMedia', controlID], '');
}
case MEDIA_LOAD_REQUEST:
return state.withMutations(map => {
map.set('isLoading', true);
map.set('isPaginating', action.payload.page > 1);
});
case MEDIA_LOAD_SUCCESS: {
const {
files = [],
page,
canPaginate,
dynamicSearch,
dynamicSearchQuery,
privateUpload,
} = action.payload;
const privateUploadChanged = state.get('privateUpload') !== privateUpload;
if (privateUploadChanged) {
return state;
}
const filesWithKeys = files.map(file => ({ ...file, key: uuid() }));
return state.withMutations(map => {
map.set('isLoading', false);
map.set('isPaginating', false);
map.set('page', page ?? 1);
map.set('hasNextPage', !!(canPaginate && files.length > 0));
map.set('dynamicSearch', dynamicSearch ?? false);
map.set('dynamicSearchQuery', dynamicSearchQuery ?? '');
map.set('dynamicSearchActive', !!dynamicSearchQuery);
if (page && page > 1) {
const updatedFiles = (map.get('files') as MediaFile[]).concat(filesWithKeys);
map.set('files', updatedFiles);
} else {
map.set('files', filesWithKeys);
}
});
}
case MEDIA_LOAD_FAILURE: {
const privateUploadChanged = state.get('privateUpload') !== action.payload.privateUpload;
if (privateUploadChanged) {
return state;
}
return state.set('isLoading', false);
}
case MEDIA_PERSIST_REQUEST:
return state.set('isPersisting', true);
case MEDIA_PERSIST_SUCCESS: {
const { file, privateUpload } = action.payload;
const privateUploadChanged = state.get('privateUpload') !== privateUpload;
if (privateUploadChanged) {
return state;
}
return state.withMutations(map => {
const fileWithKey = { ...file, key: uuid() };
const files = map.get('files') as MediaFile[];
const updatedFiles = [fileWithKey, ...files];
map.set('files', updatedFiles);
map.set('isPersisting', false);
});
}
case MEDIA_PERSIST_FAILURE: {
const privateUploadChanged = state.get('privateUpload') !== action.payload.privateUpload;
if (privateUploadChanged) {
return state;
}
return state.set('isPersisting', false);
}
case MEDIA_DELETE_REQUEST:
return state.set('isDeleting', true);
case MEDIA_DELETE_SUCCESS: {
const { file, privateUpload } = action.payload;
const { key, id } = file;
const privateUploadChanged = state.get('privateUpload') !== privateUpload;
if (privateUploadChanged) {
return state;
}
return state.withMutations(map => {
const files = map.get('files') as MediaFile[];
const updatedFiles = files.filter(file => (key ? file.key !== key : file.id !== id));
map.set('files', updatedFiles);
map.deleteIn(['displayURLs', id]);
map.set('isDeleting', false);
});
}
case MEDIA_DELETE_FAILURE: {
const privateUploadChanged = state.get('privateUpload') !== action.payload.privateUpload;
if (privateUploadChanged) {
return state;
}
return state.set('isDeleting', false);
}
case MEDIA_DISPLAY_URL_REQUEST:
return state.setIn(['displayURLs', action.payload.key, 'isFetching'], true);
case MEDIA_DISPLAY_URL_SUCCESS: {
const displayURLPath = ['displayURLs', action.payload.key];
return state
.setIn([...displayURLPath, 'isFetching'], false)
.setIn([...displayURLPath, 'url'], action.payload.url);
}
case MEDIA_DISPLAY_URL_FAILURE: {
const displayURLPath = ['displayURLs', action.payload.key];
return (
state
.setIn([...displayURLPath, 'isFetching'], false)
// make sure that err is set so the CMS won't attempt to load
// the image again
.setIn([...displayURLPath, 'err'], action.payload.err || true)
.deleteIn([...displayURLPath, 'url'])
);
}
default:
return state;
}
}
export function selectMediaFiles(state: State, field?: EntryField) {
const { mediaLibrary, entryDraft } = state;
const editingDraft = selectEditingDraft(state.entryDraft);
const integration = selectIntegration(state, null, 'assetStore');
let files;
if (editingDraft && !integration) {
const entryFiles = entryDraft
.getIn(['entry', 'mediaFiles'], List<MediaFileMap>())
.toJS() as MediaFile[];
const entry = entryDraft.get('entry');
const collection = state.collections.get(entry?.get('collection'));
const mediaFolder = selectMediaFolder(state.config, collection, entry, field);
files = entryFiles
.filter(f => dirname(f.path) === mediaFolder)
.map(file => ({ key: file.id, ...file }));
} else {
files = mediaLibrary.get('files') || [];
}
return files;
}
export function selectMediaFileByPath(state: State, path: string) {
const files = selectMediaFiles(state);
const file = files.find(file => file.path === path);
return file;
}
export function selectMediaDisplayURL(state: State, id: string) {
const displayUrlState = state.mediaLibrary.getIn(
['displayURLs', id],
Map() as unknown as DisplayURLState,
);
return displayUrlState;
}
export default mediaLibrary;

66
node_modules/decap-cms-core/src/reducers/medias.ts generated vendored Normal file
View File

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

View File

@@ -0,0 +1,52 @@
import { produce } from 'immer';
import { v4 as uuid } from 'uuid';
import {
NOTIFICATION_SEND,
NOTIFICATION_DISMISS,
NOTIFICATIONS_CLEAR,
} from '../actions/notifications';
import type {
NotificationsAction,
NotificationMessage,
NotificationPayload,
} from '../actions/notifications';
import type { TypeOptions } from 'react-toastify';
export type Notification = {
id: string;
message: string | NotificationMessage;
dismissAfter?: number;
type: TypeOptions | undefined;
};
export type NotificationsState = {
notifications: Notification[];
};
const defaultState: NotificationsState = {
notifications: [],
};
const notifications = produce((state: NotificationsState, action: NotificationsAction) => {
switch (action.type) {
case NOTIFICATIONS_CLEAR:
state.notifications = [];
break;
case NOTIFICATION_DISMISS:
state.notifications = state.notifications.filter(n => n.id !== action.id);
break;
case NOTIFICATION_SEND:
state.notifications = [
...state.notifications,
{
id: uuid(),
...(action.payload as NotificationPayload),
},
];
break;
}
}, defaultState);
export default notifications;

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

@@ -0,0 +1,111 @@
import { produce } from 'immer';
import {
QUERY_FAILURE,
QUERY_REQUEST,
QUERY_SUCCESS,
SEARCH_CLEAR,
SEARCH_ENTRIES_FAILURE,
SEARCH_ENTRIES_REQUEST,
SEARCH_ENTRIES_SUCCESS,
CLEAR_REQUESTS,
} from '../actions/search';
import type { SearchAction } from '../actions/search';
import type { EntryValue } from '../valueObjects/Entry';
export type Search = {
isFetching: boolean;
term: string;
collections: string[];
page: number;
entryIds: { collection: string; slug: string }[];
queryHits: Record<string, EntryValue[]>;
error: Error | undefined;
requests: QueryRequest[];
};
type QueryResponse = {
hits: EntryValue[];
query: string;
};
export type QueryRequest = {
id: string;
expires: Date;
queryResponse: Promise<QueryResponse>;
};
const defaultState: Search = {
isFetching: false,
term: '',
collections: [],
page: 0,
entryIds: [],
queryHits: {},
error: undefined,
requests: [],
};
const search = produce((state: Search, action: SearchAction) => {
switch (action.type) {
case SEARCH_CLEAR:
return defaultState;
case SEARCH_ENTRIES_REQUEST: {
const { page, searchTerm, searchCollections } = action.payload;
state.isFetching = true;
state.term = searchTerm;
state.collections = searchCollections;
state.page = page;
break;
}
case SEARCH_ENTRIES_SUCCESS: {
const { entries, page } = action.payload;
const entryIds = entries.map(entry => ({ collection: entry.collection, slug: entry.slug }));
state.isFetching = false;
state.page = page;
state.entryIds =
!page || isNaN(page) || page === 0 ? entryIds : state.entryIds.concat(entryIds);
break;
}
case SEARCH_ENTRIES_FAILURE: {
const { error } = action.payload;
state.isFetching = false;
state.error = error;
break;
}
case QUERY_REQUEST: {
const { searchTerm, request } = action.payload;
state.isFetching = true;
state.term = searchTerm;
if (request) {
state.requests.push(request);
}
break;
}
case CLEAR_REQUESTS: {
state.requests = state.requests.filter(req => req.expires >= new Date());
break;
}
case QUERY_SUCCESS: {
const { namespace, hits } = action.payload;
state.isFetching = false;
state.queryHits[namespace] = hits;
break;
}
case QUERY_FAILURE: {
const { error } = action.payload;
state.isFetching = false;
state.error = error;
}
}
}, defaultState);
export default search;

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

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