From 0ac17bfc25546a6a72d0fc80599404818871c597 Mon Sep 17 00:00:00 2001 From: Vladislav Shkodin Date: Sun, 14 Feb 2021 18:41:02 +0100 Subject: [PATCH] refactor: convert config actions to TypeScript (#4950) --- .eslintrc.js | 3 +- packages/netlify-cms-core/index.d.ts | 52 ++- .../src/actions/__tests__/config.spec.js | 89 ++++ .../src/actions/{config.js => config.ts} | 258 ++++++----- .../netlify-cms-core/src/types/global.d.ts | 8 + packages/netlify-cms-core/src/types/redux.ts | 412 +++++++++++++++++- 6 files changed, 704 insertions(+), 118 deletions(-) rename packages/netlify-cms-core/src/actions/{config.js => config.ts} (63%) create mode 100644 packages/netlify-cms-core/src/types/global.d.ts diff --git a/.eslintrc.js b/.eslintrc.js index dc511f580210..eaa482119370 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -69,7 +69,8 @@ module.exports = { 'require-atomic-updates': [0], 'import/no-unresolved': [0], '@typescript-eslint/no-non-null-assertion': [0], - '@typescript-eslint/explicit-function-return-type': 0, + '@typescript-eslint/camelcase': [0], + '@typescript-eslint/explicit-function-return-type': [0], '@typescript-eslint/no-use-before-define': [ 'error', { functions: false, classes: true, variables: true }, diff --git a/packages/netlify-cms-core/index.d.ts b/packages/netlify-cms-core/index.d.ts index c09c09892b55..fd4dead299f6 100644 --- a/packages/netlify-cms-core/index.d.ts +++ b/packages/netlify-cms-core/index.d.ts @@ -2,6 +2,7 @@ declare module 'netlify-cms-core' { import React, { ComponentType } from 'react'; import { List, Map } from 'immutable'; + import { FILES, FOLDER } from '../constants/collectionTypes'; export type CmsBackendType = | 'azure' @@ -9,7 +10,8 @@ declare module 'netlify-cms-core' { | 'github' | 'gitlab' | 'bitbucket' - | 'test-repo'; + | 'test-repo' + | 'proxy'; export type CmsMapWidgetType = 'Point' | 'LineString' | 'Polygon'; @@ -62,7 +64,10 @@ declare module 'netlify-cms-core' { required?: boolean; hint?: string; pattern?: [string, string]; - i18n?: boolean | 'translate' | 'duplicate'; + i18n?: boolean | 'translate' | 'duplicate' | 'none'; + media_folder?: string; + public_folder?: string; + comment?: string; } export interface CmsFieldBoolean { @@ -236,6 +241,15 @@ declare module 'netlify-cms-core' { default?: string; } + export interface CmsFieldMeta { + name: string; + label: string; + widget: string; + required: boolean; + index_file: string; + meta: boolean; + } + export type CmsField = CmsFieldBase & ( | CmsFieldBoolean @@ -252,6 +266,7 @@ declare module 'netlify-cms-core' { | CmsFieldSelect | CmsFieldHidden | CmsFieldStringOrText + | CmsFieldMeta ); export interface CmsCollectionFile { @@ -261,6 +276,25 @@ declare module 'netlify-cms-core' { fields: CmsField[]; label_singular?: string; description?: string; + preview_path?: string; + preview_path_date_field?: string; + i18n?: boolean | CmsI18nConfig; + media_folder?: string; + public_folder?: string; + } + + export interface ViewFilter { + label: string; + field: string; + pattern: string; + id: string; + } + + export interface ViewGroup { + label: string; + field: string; + pattern: string; + id: string; } export interface CmsCollection { @@ -280,6 +314,12 @@ declare module 'netlify-cms-core' { editor?: { preview?: boolean; }; + publish?: boolean; + nested?: { + depth: number; + }; + type: typeof FOLDER | typeof FILES; + meta?: { path?: { label: string; widget: string; index_file: string } }; /** * It accepts the following values: yml, yaml, toml, json, md, markdown, html @@ -296,6 +336,8 @@ declare module 'netlify-cms-core' { media_folder?: string; public_folder?: string; sortable_fields?: string[]; + view_filters?: ViewFilter[]; + view_groups?: ViewGroup[]; i18n?: boolean | CmsI18nConfig; /** @@ -316,11 +358,13 @@ declare module 'netlify-cms-core' { auth_endpoint?: string; cms_label_prefix?: string; squash_merges?: boolean; + proxy_url?: string; } export interface CmsSlug { encoding?: CmsSlugEncoding; clean_accents?: boolean; + sanitize_replacement?: string; } export interface CmsLocalBackend { @@ -341,9 +385,13 @@ declare module 'netlify-cms-core' { media_folder_relative?: boolean; media_library?: CmsMediaLibrary; publish_mode?: CmsPublishMode; + load_config_file?: boolean; slug?: CmsSlug; i18n?: CmsI18nConfig; local_backend?: boolean | CmsLocalBackend; + editor?: { + preview?: boolean; + }; } export interface InitOptions { diff --git a/packages/netlify-cms-core/src/actions/__tests__/config.spec.js b/packages/netlify-cms-core/src/actions/__tests__/config.spec.js index ef3f2778cd6e..b1b192f1fad8 100644 --- a/packages/netlify-cms-core/src/actions/__tests__/config.spec.js +++ b/packages/netlify-cms-core/src/actions/__tests__/config.spec.js @@ -1,5 +1,7 @@ import { stripIndent } from 'common-tags'; +import { fromJS } from 'immutable'; import { + loadConfig, parseConfig, normalizeConfig, applyDefaults, @@ -7,6 +9,8 @@ import { handleLocalBackend, } from '../config'; +import yaml from 'js-yaml'; + jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(console, 'warn').mockImplementation(() => {}); jest.mock('../../backend', () => { @@ -14,6 +18,7 @@ jest.mock('../../backend', () => { resolveBackend: jest.fn(() => ({ isGitBackend: jest.fn(() => true) })), }; }); +jest.mock('../../constants/configSchema'); describe('config', () => { describe('parseConfig', () => { @@ -903,4 +908,88 @@ describe('config', () => { }); }); }); + + 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: fromJS({ + 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: fromJS({ + 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)'), + }); + }); + }); }); diff --git a/packages/netlify-cms-core/src/actions/config.js b/packages/netlify-cms-core/src/actions/config.ts similarity index 63% rename from packages/netlify-cms-core/src/actions/config.js rename to packages/netlify-cms-core/src/actions/config.ts index d5fdc7227358..581dc12d6561 100644 --- a/packages/netlify-cms-core/src/actions/config.js +++ b/packages/netlify-cms-core/src/actions/config.ts @@ -2,7 +2,9 @@ import yaml from 'yaml'; import { fromJS } from 'immutable'; import deepmerge from 'deepmerge'; import { produce } from 'immer'; -import { trimStart, trim, get, isPlainObject, isEmpty } from 'lodash'; +import { trimStart, trim, isEmpty } from 'lodash'; +import { AnyAction } from 'redux'; +import { ThunkDispatch } from 'redux-thunk'; import { SIMPLE as SIMPLE_PUBLISH_MODE } from '../constants/publishModes'; import { validateConfig } from '../constants/configSchema'; import { selectDefaultSortableFields } from '../reducers/collections'; @@ -10,20 +12,43 @@ import { getIntegrations, selectIntegration } from '../reducers/integrations'; import { resolveBackend } from '../backend'; import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n'; import { FILES, FOLDER } from '../constants/collectionTypes'; +import { + CmsCollection, + CmsConfig, + CmsField, + CmsFieldBase, + CmsFieldObject, + CmsFieldList, + CmsI18nConfig, + CmsPublishMode, + CmsLocalBackend, + State, +} from '../types/redux'; export const CONFIG_REQUEST = 'CONFIG_REQUEST'; export const CONFIG_SUCCESS = 'CONFIG_SUCCESS'; export const CONFIG_FAILURE = 'CONFIG_FAILURE'; -function traverseFieldsJS(fields, updater) { +function isObjectField(field: CmsField): field is CmsFieldBase & CmsFieldObject { + return 'fields' in (field as CmsFieldObject); +} + +function isFieldList(field: CmsField): field is CmsFieldBase & CmsFieldList { + return 'types' in (field as CmsFieldList) || 'field' in (field as CmsFieldList); +} + +function traverseFieldsJS( + fields: Field[], + updater: (field: T) => T, +): Field[] { return fields.map(field => { - let newField = updater(field); - if (newField.fields) { - newField = { ...newField, fields: traverseFieldsJS(newField.fields, updater) }; - } else if (newField.field) { - newField = { ...newField, field: traverseFieldsJS([newField.field], updater)[0] }; - } else if (newField.types) { - newField = { ...newField, types: traverseFieldsJS(newField.types, updater) }; + const newField = updater(field); + if (isObjectField(newField)) { + return { ...newField, fields: traverseFieldsJS(newField.fields, updater) }; + } else if (isFieldList(newField) && newField.field) { + return { ...newField, field: traverseFieldsJS([newField.field], updater)[0] }; + } else if (isFieldList(newField) && newField.types) { + return { ...newField, types: traverseFieldsJS(newField.types, updater) }; } return newField; @@ -31,18 +56,19 @@ function traverseFieldsJS(fields, updater) { } function getConfigUrl() { - const validTypes = { 'text/yaml': 'yaml', 'application/x-yaml': 'yaml' }; - const configLinkEl = document.querySelector('link[rel="cms-config-url"]'); - const isValidLink = configLinkEl && validTypes[configLinkEl.type] && get(configLinkEl, 'href'); - if (isValidLink) { - const link = get(configLinkEl, 'href'); - console.log(`Using config file path: "${link}"`); - return link; + const validTypes: { [type: string]: string } = { + 'text/yaml': 'yaml', + 'application/x-yaml': 'yaml', + }; + const configLinkEl = document.querySelector('link[rel="cms-config-url"]'); + if (configLinkEl && validTypes[configLinkEl.type] && configLinkEl.href) { + console.log(`Using config file path: "${configLinkEl.href}"`); + return configLinkEl.href; } return 'config.yml'; } -function setDefaultPublicFolderForField(field) { +function setDefaultPublicFolderForField(field: T) { if ('media_folder' in field && !field.public_folder) { return { ...field, public_folder: field.media_folder }; } @@ -60,22 +86,25 @@ const WIDGET_KEY_MAP = { searchFields: 'search_fields', displayFields: 'display_fields', optionsLength: 'options_length', -}; +} as const; + +function setSnakeCaseConfig(field: T) { + const deprecatedKeys = Object.keys(WIDGET_KEY_MAP).filter( + camel => camel in field, + ) as ReadonlyArray; -function setSnakeCaseConfig(field) { - const deprecatedKeys = Object.keys(WIDGET_KEY_MAP).filter(camel => camel in field); const snakeValues = deprecatedKeys.map(camel => { const snake = WIDGET_KEY_MAP[camel]; console.warn( `Field ${field.name} is using a deprecated configuration '${camel}'. Please use '${snake}'`, ); - return { [snake]: field[camel] }; + return { [snake]: (field as Record)[camel] }; }); - return Object.assign({}, field, ...snakeValues); + return Object.assign({}, field, ...snakeValues) as T; } -function setI18nField(field) { +function setI18nField(field: T) { if (field[I18N] === true) { return { ...field, [I18N]: I18N_FIELD.TRANSLATE }; } else if (field[I18N] === false || !field[I18N]) { @@ -84,13 +113,16 @@ function setI18nField(field) { return field; } -function getI18nDefaults(collectionOrFileI18n, defaultI18n) { +function getI18nDefaults( + collectionOrFileI18n: boolean | CmsI18nConfig, + defaultI18n: CmsI18nConfig, +) { if (typeof collectionOrFileI18n === 'boolean') { return defaultI18n; } else { const locales = collectionOrFileI18n.locales || defaultI18n.locales; const defaultLocale = collectionOrFileI18n.default_locale || locales[0]; - const mergedI18n = deepmerge(defaultI18n, collectionOrFileI18n); + const mergedI18n: CmsI18nConfig = deepmerge(defaultI18n, collectionOrFileI18n); mergedI18n.locales = locales; mergedI18n.default_locale = defaultLocale; throwOnMissingDefaultLocale(mergedI18n); @@ -98,7 +130,7 @@ function getI18nDefaults(collectionOrFileI18n, defaultI18n) { } } -function setI18nDefaultsForFields(collectionOrFileFields, hasI18n) { +function setI18nDefaultsForFields(collectionOrFileFields: CmsField[], hasI18n: boolean) { if (hasI18n) { return traverseFieldsJS(collectionOrFileFields, setI18nField); } else { @@ -110,7 +142,7 @@ function setI18nDefaultsForFields(collectionOrFileFields, hasI18n) { } } -function throwOnInvalidFileCollectionStructure(i18n) { +function throwOnInvalidFileCollectionStructure(i18n?: CmsI18nConfig) { if (i18n && i18n.structure !== I18N_STRUCTURE.SINGLE_FILE) { throw new Error( `i18n configuration for files collections is limited to ${I18N_STRUCTURE.SINGLE_FILE} structure`, @@ -118,7 +150,7 @@ function throwOnInvalidFileCollectionStructure(i18n) { } } -function throwOnMissingDefaultLocale(i18n) { +function throwOnMissingDefaultLocale(i18n?: CmsI18nConfig) { if (i18n && i18n.default_locale && !i18n.locales.includes(i18n.default_locale)) { throw new Error( `i18n locales '${i18n.locales.join(', ')}' are missing the default locale ${ @@ -128,14 +160,14 @@ function throwOnMissingDefaultLocale(i18n) { } } -function hasIntegration(config, collection) { +function hasIntegration(config: CmsConfig, collection: CmsCollection) { // TODO remove fromJS when Immutable is removed from the integrations state slice const integrations = getIntegrations(fromJS(config)); const integration = selectIntegration(integrations, collection.name, 'listEntries'); return !!integration; } -export function normalizeConfig(config) { +export function normalizeConfig(config: CmsConfig) { const { collections = [] } = config; const normalizedCollections = collections.map(collection => { @@ -170,7 +202,7 @@ export function normalizeConfig(config) { return { ...config, collections: normalizedCollections }; } -export function applyDefaults(originalConfig) { +export function applyDefaults(originalConfig: CmsConfig) { return produce(originalConfig, config => { config.publish_mode = config.publish_mode || SIMPLE_PUBLISH_MODE; config.slug = config.slug || {}; @@ -201,13 +233,14 @@ export function applyDefaults(originalConfig) { } const i18n = config[I18N]; - const hasI18n = Boolean(i18n); - if (hasI18n) { + + if (i18n) { i18n.default_locale = i18n.default_locale || i18n.locales[0]; } throwOnMissingDefaultLocale(i18n); + // TODO remove fromJS when Immutable is removed from backend const backend = resolveBackend(fromJS(config)); for (const collection of config.collections) { @@ -215,15 +248,18 @@ export function applyDefaults(originalConfig) { collection.publish = true; } - const collectionHasI18n = Boolean(collection[I18N]); - if (hasI18n && collectionHasI18n) { - collection[I18N] = getI18nDefaults(collection[I18N], i18n); + let collectionI18n = collection[I18N]; + + if (i18n && collectionI18n) { + collectionI18n = getI18nDefaults(collectionI18n, i18n); + collection[I18N] = collectionI18n; } else { + collectionI18n = undefined; delete collection[I18N]; } if (collection.fields) { - collection.fields = setI18nDefaultsForFields(collection.fields, collectionHasI18n); + collection.fields = setI18nDefaultsForFields(collection.fields, Boolean(collectionI18n)); } const { folder, files, view_filters, view_groups, meta } = collection; @@ -260,9 +296,6 @@ export function applyDefaults(originalConfig) { if (files) { collection.type = FILES; - // after we invoked setI18nDefaults, - // i18n property can't be boolean anymore - const collectionI18n = collection[I18N]; throwOnInvalidFileCollectionStructure(collectionI18n); delete collection.nested; @@ -279,24 +312,21 @@ export function applyDefaults(originalConfig) { file.fields = traverseFieldsJS(file.fields, setDefaultPublicFolderForField); } - const fileHasI18n = Boolean(file[I18N]); + let fileI18n = file[I18N]; - if (fileHasI18n) { - if (collectionI18n) { - file[I18N] = getI18nDefaults(file[I18N], collectionI18n); - } + if (fileI18n && collectionI18n) { + fileI18n = getI18nDefaults(fileI18n, collectionI18n); + file[I18N] = fileI18n; } else { + fileI18n = undefined; delete file[I18N]; } + throwOnInvalidFileCollectionStructure(fileI18n); + if (file.fields) { - file.fields = setI18nDefaultsForFields(file.fields, fileHasI18n); + file.fields = setI18nDefaultsForFields(file.fields, Boolean(fileI18n)); } - - // after we invoked setI18nDefaults, - // i18n property can't be boolean anymore - const fileI18n = file[I18N]; - throwOnInvalidFileCollectionStructure(fileI18n); } } @@ -330,32 +360,42 @@ export function applyDefaults(originalConfig) { }); } -export function parseConfig(data) { +export function parseConfig(data: string) { const config = yaml.parse(data, { maxAliasCount: -1, prettyErrors: true, merge: true }); - if (typeof CMS_ENV === 'string' && config[CMS_ENV]) { - Object.keys(config[CMS_ENV]).forEach(key => { - config[key] = config[CMS_ENV][key]; - }); + if ( + typeof window !== 'undefined' && + typeof window.CMS_ENV === 'string' && + config[window.CMS_ENV] + ) { + const configKeys = Object.keys(config[window.CMS_ENV]) as ReadonlyArray; + for (const key of configKeys) { + config[key] = config[window.CMS_ENV][key] as CmsConfig[keyof CmsConfig]; + } } - return config; + return config as Partial; } -async function getConfigYaml(file, hasManualConfig) { - const response = await fetch(file, { credentials: 'same-origin' }).catch(err => err); +async function getConfigYaml(file: string, hasManualConfig: boolean) { + const response = await fetch(file, { credentials: 'same-origin' }).catch(error => error as Error); if (response instanceof Error || response.status !== 200) { - if (hasManualConfig) return parseConfig(''); - throw new Error(`Failed to load config.yml (${response.status || response})`); + if (hasManualConfig) { + return {}; + } + const message = response instanceof Error ? response.message : response.status; + throw new Error(`Failed to load config.yml (${message})`); } const contentType = response.headers.get('Content-Type') || 'Not-Found'; const isYaml = contentType.indexOf('yaml') !== -1; if (!isYaml) { console.log(`Response for ${file} was not yaml. (Content-Type: ${contentType})`); - if (hasManualConfig) return parseConfig(''); + if (hasManualConfig) { + return {}; + } } return parseConfig(await response.text()); } -export function configLoaded(config) { +export function configLoaded(config: CmsConfig) { return { type: CONFIG_SUCCESS, payload: config, @@ -368,7 +408,7 @@ export function configLoading() { }; } -export function configFailed(err) { +export function configFailed(err: Error) { return { type: CONFIG_FAILURE, error: 'Error loading config', @@ -376,35 +416,49 @@ export function configFailed(err) { }; } -export async function detectProxyServer(localBackend) { - const allowedHosts = ['localhost', '127.0.0.1', ...(localBackend?.allowed_hosts || [])]; - if (allowedHosts.includes(location.hostname)) { - let proxyUrl; - const defaultUrl = 'http://localhost:8081/api/v1'; - if (localBackend === true) { - proxyUrl = defaultUrl; - } else if (isPlainObject(localBackend)) { - proxyUrl = localBackend.url || defaultUrl.replace('localhost', location.hostname); - } - try { - console.log(`Looking for Netlify CMS Proxy Server at '${proxyUrl}'`); - const { repo, publish_modes, type } = await fetch(`${proxyUrl}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'info' }), - }).then(res => res.json()); - if (typeof repo === 'string' && Array.isArray(publish_modes) && typeof type === 'string') { - console.log(`Detected Netlify CMS Proxy Server at '${proxyUrl}' with repo: '${repo}'`); - return { proxyUrl, publish_modes, type }; - } - } catch { +export async function detectProxyServer(localBackend?: boolean | CmsLocalBackend) { + const allowedHosts = [ + 'localhost', + '127.0.0.1', + ...(typeof localBackend === 'boolean' ? [] : localBackend?.allowed_hosts || []), + ]; + + if (!allowedHosts.includes(location.hostname) || !localBackend) { + return {}; + } + + const defaultUrl = 'http://localhost:8081/api/v1'; + const proxyUrl = + localBackend === true + ? defaultUrl + : localBackend.url || defaultUrl.replace('localhost', location.hostname); + + try { + console.log(`Looking for Netlify CMS Proxy Server at '${proxyUrl}'`); + const res = await fetch(`${proxyUrl}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'info' }), + }); + const { repo, publish_modes, type } = (await res.json()) as { + repo?: string; + publish_modes?: CmsPublishMode[]; + type?: string; + }; + if (typeof repo === 'string' && Array.isArray(publish_modes) && typeof type === 'string') { + console.log(`Detected Netlify CMS Proxy Server at '${proxyUrl}' with repo: '${repo}'`); + return { proxyUrl, publish_modes, type }; + } else { console.log(`Netlify CMS Proxy Server not detected at '${proxyUrl}'`); + return {}; } + } catch { + console.log(`Netlify CMS Proxy Server not detected at '${proxyUrl}'`); + return {}; } - return {}; } -function getPublishMode(config, publishModes, backendType) { +function getPublishMode(config: CmsConfig, publishModes?: CmsPublishMode[], backendType?: string) { if (config.publish_mode && publishModes && !publishModes.includes(config.publish_mode)) { const newPublishMode = publishModes[0]; console.log( @@ -416,32 +470,34 @@ function getPublishMode(config, publishModes, backendType) { return config.publish_mode; } -export async function handleLocalBackend(config) { - if (!config.local_backend) { - return config; +export async function handleLocalBackend(originalConfig: CmsConfig) { + if (!originalConfig.local_backend) { + return originalConfig; } const { proxyUrl, publish_modes: publishModes, type: backendType } = await detectProxyServer( - config.local_backend, + originalConfig.local_backend, ); if (!proxyUrl) { - return config; + return originalConfig; } - const publishMode = getPublishMode(config, publishModes, backendType); - return { - ...config, - ...(publishMode && { publish_mode: publishMode }), - backend: { ...config.backend, name: 'proxy', proxy_url: proxyUrl }, - }; + return produce(originalConfig, config => { + config.backend.name = 'proxy'; + config.backend.proxy_url = proxyUrl; + + if (config.publish_mode) { + config.publish_mode = getPublishMode(config, publishModes, backendType); + } + }); } -export function loadConfig(manualConfig = {}, onLoad) { +export function loadConfig(manualConfig: Partial = {}, onLoad: () => unknown) { if (window.CMS_CONFIG) { return configLoaded(fromJS(window.CMS_CONFIG)); } - return async dispatch => { + return async (dispatch: ThunkDispatch) => { dispatch(configLoading()); try { diff --git a/packages/netlify-cms-core/src/types/global.d.ts b/packages/netlify-cms-core/src/types/global.d.ts new file mode 100644 index 000000000000..07a295dbd8e0 --- /dev/null +++ b/packages/netlify-cms-core/src/types/global.d.ts @@ -0,0 +1,8 @@ +import { CmsConfig } from './redux'; + +declare global { + interface Window { + CMS_CONFIG?: CmsConfig; + CMS_ENV?: string; + } +} diff --git a/packages/netlify-cms-core/src/types/redux.ts b/packages/netlify-cms-core/src/types/redux.ts index 327c4ba828cb..0ef939c2894d 100644 --- a/packages/netlify-cms-core/src/types/redux.ts +++ b/packages/netlify-cms-core/src/types/redux.ts @@ -1,11 +1,409 @@ import { Action } from 'redux'; import { StaticallyTypedRecord } from './immutable'; import { Map, List, OrderedMap, Set } from 'immutable'; +import { FILES, FOLDER } from '../constants/collectionTypes'; import { MediaFile as BackendMediaFile } from '../backend'; import { Auth } from '../reducers/auth'; import { Status } from '../reducers/status'; import { Medias } from '../reducers/medias'; +export type CmsBackendType = + | 'azure' + | 'git-gateway' + | 'github' + | 'gitlab' + | 'bitbucket' + | 'test-repo' + | 'proxy'; + +export type CmsMapWidgetType = 'Point' | 'LineString' | 'Polygon'; + +export type CmsMarkdownWidgetButton = + | 'bold' + | 'italic' + | 'code' + | 'link' + | 'heading-one' + | 'heading-two' + | 'heading-three' + | 'heading-four' + | 'heading-five' + | 'heading-six' + | 'quote' + | 'code-block' + | 'bulleted-list' + | 'numbered-list'; + +export interface CmsSelectWidgetOptionObject { + label: string; + value: unknown; +} + +export type CmsCollectionFormatType = + | 'yml' + | 'yaml' + | 'toml' + | 'json' + | 'frontmatter' + | 'yaml-frontmatter' + | 'toml-frontmatter' + | 'json-frontmatter'; + +export type CmsAuthScope = 'repo' | 'public_repo'; + +export type CmsPublishMode = 'simple' | 'editorial_workflow'; + +export type CmsSlugEncoding = 'unicode' | 'ascii'; + +export interface CmsI18nConfig { + structure: 'multiple_folders' | 'multiple_files' | 'single_file'; + locales: string[]; + default_locale?: string; +} + +export interface CmsFieldBase { + name: string; + label?: string; + required?: boolean; + hint?: string; + pattern?: [string, string]; + i18n?: boolean | 'translate' | 'duplicate' | 'none'; + media_folder?: string; + public_folder?: string; + comment?: string; +} + +export interface CmsFieldBoolean { + widget: 'boolean'; + default?: boolean; +} + +export interface CmsFieldCode { + widget: 'code'; + default?: unknown; + + default_language?: string; + allow_language_selection?: boolean; + keys?: { code: string; lang: string }; + output_code_only?: boolean; +} + +export interface CmsFieldColor { + widget: 'color'; + default?: string; + + allowInput?: boolean; + enableAlpha?: boolean; +} + +export interface CmsFieldDateTime { + widget: 'datetime'; + default?: string; + + format?: string; + date_format?: boolean | string; + time_format?: boolean | string; + picker_utc?: boolean; + + /** + * @deprecated Use date_format instead + */ + dateFormat?: boolean | string; + /** + * @deprecated Use time_format instead + */ + timeFormat?: boolean | string; + /** + * @deprecated Use picker_utc instead + */ + pickerUtc?: boolean; +} + +export interface CmsFieldFileOrImage { + widget: 'file' | 'image'; + default?: string; + + media_library?: CmsMediaLibrary; + allow_multiple?: boolean; + config?: unknown; +} + +export interface CmsFieldObject { + widget: 'object'; + default?: unknown; + + collapsed?: boolean; + summary?: string; + fields: CmsField[]; +} + +export interface CmsFieldList { + widget: 'list'; + default?: unknown; + + allow_add?: boolean; + collapsed?: boolean; + summary?: string; + minimize_collapsed?: boolean; + label_singular?: string; + field?: CmsField; + fields?: CmsField[]; + max?: number; + min?: number; + add_to_top?: boolean; + types?: (CmsFieldBase & CmsFieldObject)[]; +} + +export interface CmsFieldMap { + widget: 'map'; + default?: string; + + decimals?: number; + type?: CmsMapWidgetType; +} + +export interface CmsFieldMarkdown { + widget: 'markdown'; + default?: string; + + minimal?: boolean; + buttons?: CmsMarkdownWidgetButton[]; + editor_components?: string[]; + modes?: ('raw' | 'rich_text')[]; + + /** + * @deprecated Use editor_components instead + */ + editorComponents?: string[]; +} + +export interface CmsFieldNumber { + widget: 'number'; + default?: string | number; + + value_type?: 'int' | 'float' | string; + min?: number; + max?: number; + + step?: number; + + /** + * @deprecated Use valueType instead + */ + valueType?: 'int' | 'float' | string; +} + +export interface CmsFieldSelect { + widget: 'select'; + default?: string | string[]; + + options: string[] | CmsSelectWidgetOptionObject[]; + multiple?: boolean; + min?: number; + max?: number; +} + +export interface CmsFieldRelation { + widget: 'relation'; + default?: string | string[]; + + collection: string; + value_field: string; + search_fields: string[]; + file?: string; + display_fields?: string[]; + multiple?: boolean; + options_length?: number; + + /** + * @deprecated Use value_field instead + */ + valueField?: string; + /** + * @deprecated Use search_fields instead + */ + searchFields?: string[]; + /** + * @deprecated Use display_fields instead + */ + displayFields?: string[]; + /** + * @deprecated Use options_length instead + */ + optionsLength?: number; +} + +export interface CmsFieldHidden { + widget: 'hidden'; + default?: unknown; +} + +export interface CmsFieldStringOrText { + // This is the default widget, so declaring its type is optional. + widget?: 'string' | 'text'; + default?: string; +} + +export interface CmsFieldMeta { + name: string; + label: string; + widget: string; + required: boolean; + index_file: string; + meta: boolean; +} + +export type CmsField = CmsFieldBase & + ( + | CmsFieldBoolean + | CmsFieldCode + | CmsFieldColor + | CmsFieldDateTime + | CmsFieldFileOrImage + | CmsFieldList + | CmsFieldMap + | CmsFieldMarkdown + | CmsFieldNumber + | CmsFieldObject + | CmsFieldRelation + | CmsFieldSelect + | CmsFieldHidden + | CmsFieldStringOrText + | CmsFieldMeta + ); + +export interface CmsCollectionFile { + name: string; + label: string; + file: string; + fields: CmsField[]; + label_singular?: string; + description?: string; + preview_path?: string; + preview_path_date_field?: string; + i18n?: boolean | CmsI18nConfig; + media_folder?: string; + public_folder?: string; +} + +export interface ViewFilter { + label: string; + field: string; + pattern: string; + id: string; +} + +export interface ViewGroup { + label: string; + field: string; + pattern: string; + id: string; +} + +export interface CmsCollection { + name: string; + label: string; + label_singular?: string; + description?: string; + folder?: string; + files?: CmsCollectionFile[]; + identifier_field?: string; + summary?: string; + slug?: string; + preview_path?: string; + preview_path_date_field?: string; + create?: boolean; + delete?: boolean; + editor?: { + preview?: boolean; + }; + publish?: boolean; + nested?: { + depth: number; + }; + type: typeof FOLDER | typeof FILES; + meta?: { path?: { label: string; widget: string; index_file: string } }; + + /** + * It accepts the following values: yml, yaml, toml, json, md, markdown, html + * + * You may also specify a custom extension not included in the list above, by specifying the format value. + */ + extension?: string; + format?: CmsCollectionFormatType; + + frontmatter_delimiter?: string[] | string; + fields?: CmsField[]; + filter?: { field: string; value: unknown }; + path?: string; + media_folder?: string; + public_folder?: string; + sortable_fields?: string[]; + view_filters?: ViewFilter[]; + view_groups?: ViewGroup[]; + i18n?: boolean | CmsI18nConfig; + + /** + * @deprecated Use sortable_fields instead + */ + sortableFields?: string[]; +} + +export interface CmsBackend { + name: CmsBackendType; + auth_scope?: CmsAuthScope; + open_authoring?: boolean; + repo?: string; + branch?: string; + api_root?: string; + site_domain?: string; + base_url?: string; + auth_endpoint?: string; + cms_label_prefix?: string; + squash_merges?: boolean; + proxy_url?: string; +} + +export interface CmsSlug { + encoding?: CmsSlugEncoding; + clean_accents?: boolean; + sanitize_replacement?: string; +} + +export interface CmsLocalBackend { + url?: string; + allowed_hosts?: string[]; +} + +export interface CmsConfig { + backend: CmsBackend; + collections: CmsCollection[]; + locale?: string; + site_url?: string; + display_url?: string; + logo_url?: string; + show_preview_links?: boolean; + media_folder?: string; + public_folder?: string; + media_folder_relative?: boolean; + media_library?: CmsMediaLibrary; + publish_mode?: CmsPublishMode; + load_config_file?: boolean; + slug?: CmsSlug; + i18n?: CmsI18nConfig; + local_backend?: boolean | CmsLocalBackend; + editor?: { + preview?: boolean; + }; +} + +export type CmsMediaLibraryOptions = unknown; // TODO: type properly + +export interface CmsMediaLibrary { + name: string; + config?: CmsMediaLibraryOptions; +} + export type SlugConfig = StaticallyTypedRecord<{ encoding: string; clean_accents: boolean; @@ -162,20 +560,6 @@ export type CollectionFile = StaticallyTypedRecord<{ export type CollectionFiles = List; -export type ViewFilter = { - label: string; - field: string; - pattern: string; - id: string; -}; - -export type ViewGroup = { - label: string; - field: string; - pattern: string; - id: string; -}; - type NestedObject = { depth: number }; type Nested = StaticallyTypedRecord;