decap-cms-core
Version:
Decap CMS core application, see decap-cms package for the main distribution.
1,010 lines (932 loc) • 32.1 kB
JavaScript
import { stripIndent } from 'common-tags';
import yaml from 'js-yaml';
import {
loadConfig,
parseConfig,
normalizeConfig,
applyDefaults,
detectProxyServer,
handleLocalBackend,
} from '../config';
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.mock('../../backend', () => {
return {
resolveBackend: jest.fn(() => ({ isGitBackend: jest.fn(() => true) })),
};
});
jest.mock('../../constants/configSchema');
describe('config', () => {
describe('parseConfig', () => {
it('can parse simple yaml config', () => {
const file = stripIndent`
backend:
name: test-repo
media_folder: static/images
`;
expect(parseConfig(file)).toEqual({
backend: { name: 'test-repo' },
media_folder: 'static/images',
});
});
it('should merge yaml aliases', () => {
const file = stripIndent`
backend:
name: github
repo: decaporg/decap-cms
squash_merges: true
open_authoring: true
local_backend: true
site_url: https://www.decapcms.org
publish_mode: editorial_workflow
media_folder: website/static/img
public_folder: img
docs_collection: &docs_collection
folder: website/content/docs
create: true
preview_path: 'docs/{{slug}}'
fields:
- { label: Title, name: title }
- { label: Body, name: body, widget: markdown }
collections:
- <<: *docs_collection
name: docs_start
label: 'Docs: Quick Start'
`;
expect(parseConfig(file)).toEqual({
backend: {
name: 'github',
repo: 'decaporg/decap-cms',
squash_merges: true,
open_authoring: true,
},
local_backend: true,
site_url: 'https://www.decapcms.org',
publish_mode: 'editorial_workflow',
media_folder: 'website/static/img',
public_folder: 'img',
docs_collection: {
folder: 'website/content/docs',
create: true,
preview_path: 'docs/{{slug}}',
fields: [
{ label: 'Title', name: 'title' },
{ label: 'Body', name: 'body', widget: 'markdown' },
],
},
collections: [
{
folder: 'website/content/docs',
create: true,
preview_path: 'docs/{{slug}}',
fields: [
{ label: 'Title', name: 'title' },
{ label: 'Body', name: 'body', widget: 'markdown' },
],
name: 'docs_start',
label: 'Docs: Quick Start',
},
],
});
});
});
describe('applyDefaults', () => {
describe('publish_mode', () => {
it('should set publish_mode if not set', () => {
const config = {
foo: 'bar',
media_folder: 'path/to/media',
public_folder: '/path/to/media',
collections: [],
};
expect(applyDefaults(config).publish_mode).toEqual('simple');
});
it('should set publish_mode from config', () => {
const config = {
foo: 'bar',
publish_mode: 'complex',
media_folder: 'path/to/media',
public_folder: '/path/to/media',
collections: [],
};
expect(applyDefaults(config).publish_mode).toEqual('complex');
});
});
describe('public_folder', () => {
it('should set public_folder based on media_folder if not set', () => {
expect(
applyDefaults({
foo: 'bar',
media_folder: 'path/to/media',
collections: [],
}).public_folder,
).toEqual('/path/to/media');
});
it('should not overwrite public_folder if set', () => {
expect(
applyDefaults({
foo: 'bar',
media_folder: 'path/to/media',
public_folder: '/publib/path',
collections: [],
}).public_folder,
).toEqual('/publib/path');
expect(
applyDefaults({
foo: 'bar',
media_folder: 'path/to/media',
public_folder: '',
collections: [],
}).public_folder,
).toEqual('');
});
});
describe('slug', () => {
it('should set default slug config if not set', () => {
expect(applyDefaults({ collections: [] }).slug).toEqual({
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
});
});
it('should not overwrite slug encoding if set', () => {
expect(
applyDefaults({ collections: [], slug: { encoding: 'ascii' } }).slug.encoding,
).toEqual('ascii');
});
it('should not overwrite slug clean_accents if set', () => {
expect(
applyDefaults({ collections: [], slug: { clean_accents: true } }).slug.clean_accents,
).toEqual(true);
});
it('should not overwrite slug sanitize_replacement if set', () => {
expect(
applyDefaults({ collections: [], slug: { sanitize_replacement: '_' } }).slug
.sanitize_replacement,
).toEqual('_');
});
});
describe('collections', () => {
it('should strip leading slashes from collection folder', () => {
expect(
applyDefaults({
collections: [{ folder: '/foo', fields: [{ name: 'title', widget: 'string' }] }],
}).collections[0].folder,
).toEqual('foo');
});
it('should strip leading slashes from collection files', () => {
expect(
applyDefaults({
collections: [
{ files: [{ file: '/foo', fields: [{ name: 'title', widget: 'string' }] }] },
],
}).collections[0].files[0].file,
).toEqual('foo');
});
describe('public_folder and media_folder', () => {
it('should set collection public_folder based on media_folder if not set', () => {
expect(
applyDefaults({
collections: [
{
folder: 'foo',
media_folder: 'static/images/docs',
fields: [{ name: 'title', widget: 'string' }],
},
],
}).collections[0].public_folder,
).toEqual('static/images/docs');
});
it('should not overwrite collection public_folder if set to non empty string', () => {
expect(
applyDefaults({
collections: [
{
folder: 'foo',
media_folder: 'static/images/docs',
public_folder: 'images/docs',
fields: [{ name: 'title', widget: 'string' }],
},
],
}).collections[0].public_folder,
).toEqual('images/docs');
});
it('should not overwrite collection public_folder if set to empty string', () => {
expect(
applyDefaults({
collections: [
{
folder: 'foo',
media_folder: 'static/images/docs',
public_folder: '',
fields: [{ name: 'title', widget: 'string' }],
},
],
}).collections[0].public_folder,
).toEqual('');
});
it("should set collection media_folder and public_folder to an empty string when collection path exists, but collection media_folder doesn't", () => {
const result = applyDefaults({
collections: [
{
folder: 'foo',
path: '{{slug}}/index',
fields: [{ name: 'title', widget: 'string' }],
},
],
});
expect(result.collections[0].media_folder).toEqual('');
expect(result.collections[0].public_folder).toEqual('');
});
it('should set file public_folder based on media_folder if not set', () => {
expect(
applyDefaults({
collections: [
{
files: [
{
file: 'foo',
media_folder: 'static/images/docs',
fields: [{ name: 'title', widget: 'string' }],
},
],
},
],
}).collections[0].files[0].public_folder,
).toEqual('static/images/docs');
});
it('should not overwrite file public_folder if set', () => {
expect(
applyDefaults({
collections: [
{
files: [
{
file: 'foo',
media_folder: 'static/images/docs',
public_folder: 'images/docs',
fields: [{ name: 'title', widget: 'string' }],
},
],
},
],
}).collections[0].files[0].public_folder,
).toEqual('images/docs');
});
it('should set nested field public_folder based on media_folder if not set', () => {
const config = applyDefaults({
collections: [
{
folder: 'foo',
path: '{{slug}}/index',
fields: [
{
name: 'title',
widget: 'string',
media_folder: 'collection/static/images/docs',
},
],
},
{
files: [
{
file: 'foo',
fields: [
{
name: 'title',
widget: 'string',
media_folder: 'file/static/images/docs',
},
],
},
],
},
],
});
expect(config.collections[0].fields[0].public_folder).toEqual(
'collection/static/images/docs',
);
expect(config.collections[1].files[0].fields[0].public_folder).toEqual(
'file/static/images/docs',
);
});
it('should not overwrite nested field public_folder if set', () => {
const config = applyDefaults({
collections: [
{
folder: 'foo',
path: '{{slug}}/index',
fields: [
{
name: 'title',
widget: 'string',
media_folder: 'collection/static/images/docs',
public_folder: 'collection/public_folder',
},
],
},
{
files: [
{
file: 'foo',
fields: [
{
name: 'title',
widget: 'string',
public_folder: 'file/public_folder',
},
],
},
],
},
],
});
expect(config.collections[0].fields[0].public_folder).toEqual('collection/public_folder');
expect(config.collections[1].files[0].fields[0].public_folder).toEqual(
'file/public_folder',
);
});
});
describe('publish', () => {
it('should set publish to true if not set', () => {
expect(
applyDefaults({
collections: [
{
folder: 'foo',
media_folder: 'static/images/docs',
fields: [{ name: 'title', widget: 'string' }],
},
],
}).collections[0].publish,
).toEqual(true);
});
it('should not override existing publish config', () => {
expect(
applyDefaults({
collections: [
{
folder: 'foo',
media_folder: 'static/images/docs',
publish: false,
fields: [{ name: 'title', widget: 'string' }],
},
],
}).collections[0].publish,
).toEqual(false);
});
});
describe('editor preview', () => {
it('should set editor preview honoring global config before and specific config after', () => {
const config = applyDefaults({
editor: {
preview: false,
},
collections: [
{
fields: [{ name: 'title' }],
folder: 'foo',
},
{
editor: {
preview: true,
},
fields: [{ name: 'title' }],
folder: 'bar',
},
],
});
expect(config.collections[0].editor.preview).toEqual(false);
expect(config.collections[1].editor.preview).toEqual(true);
});
});
});
test('should convert camel case to snake case', () => {
expect(
applyDefaults(
normalizeConfig({
collections: [
{
sortableFields: ['title'],
folder: 'src',
identifier_field: 'datetime',
fields: [
{
name: 'datetime',
widget: 'datetime',
dateFormat: 'YYYY/MM/DD',
timeFormat: 'HH:mm',
pickerUtc: true,
},
{
widget: 'number',
valueType: 'float',
},
],
},
{
sortableFields: [],
files: [
{
name: 'file',
file: 'src/file.json',
fields: [
{
widget: 'markdown',
editorComponents: ['code'],
},
{
widget: 'relation',
valueField: 'title',
searchFields: ['title'],
displayFields: ['title'],
optionsLength: 5,
},
],
},
],
},
],
}),
),
).toEqual({
collections: [
{
sortable_fields: ['title'],
folder: 'src',
type: 'folder_based_collection',
view_filters: [],
view_groups: [],
identifier_field: 'datetime',
fields: [
{
name: 'datetime',
widget: 'datetime',
date_format: 'YYYY/MM/DD',
dateFormat: 'YYYY/MM/DD',
time_format: 'HH:mm',
timeFormat: 'HH:mm',
picker_utc: true,
pickerUtc: true,
},
{
widget: 'number',
value_type: 'float',
valueType: 'float',
},
],
publish: true,
},
{
sortable_fields: [],
files: [
{
name: 'file',
file: 'src/file.json',
fields: [
{
widget: 'markdown',
editor_components: ['code'],
editorComponents: ['code'],
},
{
widget: 'relation',
value_field: 'title',
valueField: 'title',
search_fields: ['title'],
searchFields: ['title'],
display_fields: ['title'],
displayFields: ['title'],
options_length: 5,
optionsLength: 5,
},
],
},
],
type: 'file_based_collection',
view_filters: [],
view_groups: [],
publish: true,
},
],
public_folder: '/',
publish_mode: 'simple',
slug: { clean_accents: false, encoding: 'unicode', sanitize_replacement: '-' },
});
});
describe('i18n', () => {
it('should set root i18n on collection when collection i18n is set to true', () => {
expect(
applyDefaults({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [
{ folder: 'foo', i18n: true, fields: [{ name: 'title', widget: 'string' }] },
],
}).collections[0].i18n,
).toEqual({ structure: 'multiple_folders', locales: ['en', 'de'], default_locale: 'en' });
});
it('should not set root i18n on collection when collection i18n is not set', () => {
expect(
applyDefaults({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [{ folder: 'foo', fields: [{ name: 'title', widget: 'string' }] }],
}).collections[0].i18n,
).toBeUndefined();
});
it('should not set root i18n on collection when collection i18n is set to false', () => {
expect(
applyDefaults({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [
{ folder: 'foo', i18n: false, fields: [{ name: 'title', widget: 'string' }] },
],
}).collections[0].i18n,
).toBeUndefined();
});
it('should merge root i18n on collection when collection i18n is set to an object', () => {
expect(
applyDefaults({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
default_locale: 'en',
},
collections: [
{
folder: 'foo',
i18n: { locales: ['en', 'fr'], default_locale: 'fr' },
fields: [{ name: 'title', widget: 'string' }],
},
],
}).collections[0].i18n,
).toEqual({ structure: 'multiple_folders', locales: ['en', 'fr'], default_locale: 'fr' });
});
it('should throw when i18n structure is not single_file on files collection', () => {
expect(() =>
applyDefaults({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [
{
files: [
{
name: 'file',
file: 'file',
i18n: true,
fields: [{ name: 'title', widget: 'string', i18n: true }],
},
],
i18n: true,
},
],
}),
).toThrow('i18n configuration for files collections is limited to single_file structure');
});
it('should throw when i18n structure is set to multiple_folders and contains a single file collection', () => {
expect(() =>
applyDefaults({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [
{
files: [
{ name: 'file', file: 'file', fields: [{ name: 'title', widget: 'string' }] },
],
i18n: true,
},
],
}),
).toThrow('i18n configuration for files collections is limited to single_file structure');
});
it('should throw when i18n structure is set to multiple_files and contains a single file collection', () => {
expect(() =>
applyDefaults({
i18n: {
structure: 'multiple_files',
locales: ['en', 'de'],
},
collections: [
{
files: [
{ name: 'file', file: 'file', fields: [{ name: 'title', widget: 'string' }] },
],
i18n: true,
},
],
}),
).toThrow('i18n configuration for files collections is limited to single_file structure');
});
it('should set i18n value to translate on field when i18n=true for field in files collection', () => {
expect(
applyDefaults({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [
{
files: [
{
name: 'file',
file: 'file',
i18n: true,
fields: [{ name: 'title', widget: 'string', i18n: true }],
},
],
i18n: {
structure: 'single_file',
},
},
],
}).collections[0].files[0].fields[0].i18n,
).toEqual('translate');
});
it('should set i18n value to translate on field when i18n=true for field', () => {
expect(
applyDefaults({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [
{
folder: 'foo',
i18n: true,
fields: [{ name: 'title', widget: 'string', i18n: true }],
},
],
}).collections[0].fields[0].i18n,
).toEqual('translate');
});
it('should set i18n value to none on field when i18n=false for field', () => {
expect(
applyDefaults({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [
{
folder: 'foo',
i18n: true,
fields: [{ name: 'title', widget: 'string', i18n: false }],
},
],
}).collections[0].fields[0].i18n,
).toEqual('none');
});
it('should throw is default locale is missing from root i18n config', () => {
expect(() =>
applyDefaults({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
default_locale: 'fr',
},
collections: [
{
folder: 'foo',
fields: [{ name: 'title', widget: 'string' }],
},
],
}),
).toThrow("i18n locales 'en, de' are missing the default locale fr");
});
it('should throw is default locale is missing from collection i18n config', () => {
expect(() =>
applyDefaults({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [
{
folder: 'foo',
i18n: {
default_locale: 'fr',
},
fields: [{ name: 'title', widget: 'string' }],
},
],
}),
).toThrow("i18n locales 'en, de' are missing the default locale fr");
});
});
});
describe('detectProxyServer', () => {
function assetFetchCalled(url = 'http://localhost:8081/api/v1') {
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'info' }),
});
}
beforeEach(() => {
delete window.location;
});
it('should return empty object when not on localhost', async () => {
window.location = { hostname: 'www.netlify.com' };
global.fetch = jest.fn();
await expect(detectProxyServer()).resolves.toEqual({});
expect(global.fetch).toHaveBeenCalledTimes(0);
});
it('should return empty object when fetch returns an error', async () => {
window.location = { hostname: 'localhost' };
global.fetch = jest.fn().mockRejectedValue(new Error());
await expect(detectProxyServer(true)).resolves.toEqual({});
assetFetchCalled();
});
it('should return empty object when fetch returns an invalid response', async () => {
window.location = { hostname: 'localhost' };
global.fetch = jest
.fn()
.mockResolvedValue({ json: jest.fn().mockResolvedValue({ repo: [] }) });
await expect(detectProxyServer(true)).resolves.toEqual({});
assetFetchCalled();
});
it('should return result object when fetch returns a valid response', async () => {
window.location = { hostname: 'localhost' };
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
repo: 'test-repo',
publish_modes: ['simple', 'editorial_workflow'],
type: 'local_git',
}),
});
await expect(detectProxyServer(true)).resolves.toEqual({
proxyUrl: 'http://localhost:8081/api/v1',
publish_modes: ['simple', 'editorial_workflow'],
type: 'local_git',
});
assetFetchCalled();
});
it('should use local_backend url', async () => {
const url = 'http://localhost:8082/api/v1';
window.location = { hostname: 'localhost' };
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
repo: 'test-repo',
publish_modes: ['simple', 'editorial_workflow'],
type: 'local_git',
}),
});
await expect(detectProxyServer({ url })).resolves.toEqual({
proxyUrl: url,
publish_modes: ['simple', 'editorial_workflow'],
type: 'local_git',
});
assetFetchCalled(url);
});
it('should use local_backend allowed_hosts', async () => {
const allowed_hosts = ['192.168.0.1'];
window.location = { hostname: '192.168.0.1' };
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
repo: 'test-repo',
publish_modes: ['simple', 'editorial_workflow'],
type: 'local_git',
}),
});
await expect(detectProxyServer({ allowed_hosts })).resolves.toEqual({
proxyUrl: 'http://192.168.0.1:8081/api/v1',
publish_modes: ['simple', 'editorial_workflow'],
type: 'local_git',
});
assetFetchCalled('http://192.168.0.1:8081/api/v1');
});
});
describe('handleLocalBackend', () => {
beforeEach(() => {
delete window.location;
});
it('should not replace backend config when proxy is not detected', async () => {
window.location = { hostname: 'localhost' };
global.fetch = jest.fn().mockRejectedValue(new Error());
const config = { local_backend: true, backend: { name: 'github' } };
const actual = await handleLocalBackend(config);
expect(actual).toEqual(config);
});
it('should replace backend config when proxy is detected', async () => {
window.location = { hostname: 'localhost' };
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
repo: 'test-repo',
publish_modes: ['simple', 'editorial_workflow'],
type: 'local_git',
}),
});
const config = { local_backend: true, backend: { name: 'github' } };
const actual = await handleLocalBackend(config);
expect(actual).toEqual({
local_backend: true,
backend: { name: 'proxy', proxy_url: 'http://localhost:8081/api/v1' },
});
});
it('should replace publish mode when not supported by proxy', async () => {
window.location = { hostname: 'localhost' };
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
repo: 'test-repo',
publish_modes: ['simple'],
type: 'local_fs',
}),
});
const config = {
local_backend: true,
publish_mode: 'editorial_workflow',
backend: { name: 'github' },
};
const actual = await handleLocalBackend(config);
expect(actual).toEqual({
local_backend: true,
publish_mode: 'simple',
backend: { name: 'proxy', proxy_url: 'http://localhost:8081/api/v1' },
});
});
});
describe('loadConfig', () => {
beforeEach(() => {
document.querySelector = jest.fn();
global.fetch = jest.fn();
});
test(`should fetch default 'config.yml'`, async () => {
const dispatch = jest.fn();
global.fetch.mockResolvedValue({
status: 200,
text: () => Promise.resolve(yaml.dump({ backend: { repo: 'test-repo' } })),
headers: new Headers(),
});
await loadConfig()(dispatch);
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith('config.yml', { credentials: 'same-origin' });
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenCalledWith({ type: 'CONFIG_REQUEST' });
expect(dispatch).toHaveBeenCalledWith({
type: 'CONFIG_SUCCESS',
payload: {
backend: { repo: 'test-repo' },
collections: [],
publish_mode: 'simple',
slug: { encoding: 'unicode', clean_accents: false, sanitize_replacement: '-' },
public_folder: '/',
},
});
});
test(`should fetch from custom 'config.yml'`, async () => {
const dispatch = jest.fn();
document.querySelector.mockReturnValue({ type: 'text/yaml', href: 'custom-config.yml' });
global.fetch.mockResolvedValue({
status: 200,
text: () => Promise.resolve(yaml.dump({ backend: { repo: 'github' } })),
headers: new Headers(),
});
await loadConfig()(dispatch);
expect(document.querySelector).toHaveBeenCalledTimes(1);
expect(document.querySelector).toHaveBeenCalledWith('link[rel="cms-config-url"]');
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith('custom-config.yml', {
credentials: 'same-origin',
});
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenCalledWith({ type: 'CONFIG_REQUEST' });
expect(dispatch).toHaveBeenCalledWith({
type: 'CONFIG_SUCCESS',
payload: {
backend: { repo: 'github' },
collections: [],
publish_mode: 'simple',
slug: { encoding: 'unicode', clean_accents: false, sanitize_replacement: '-' },
public_folder: '/',
},
});
});
test(`should throw on failure to fetch 'config.yml'`, async () => {
const dispatch = jest.fn();
global.fetch.mockRejectedValue(new Error('Failed to fetch'));
await expect(() => loadConfig()(dispatch)).rejects.toEqual(
new Error('Failed to load config.yml (Failed to fetch)'),
);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenCalledWith({ type: 'CONFIG_REQUEST' });
expect(dispatch).toHaveBeenCalledWith({
type: 'CONFIG_FAILURE',
error: 'Error loading config',
payload: new Error('Failed to load config.yml (Failed to fetch)'),
});
});
});
});