From 6bf8a6fa9f93d16bc71d365760a5930a0cf17047 Mon Sep 17 00:00:00 2001 From: barthc Date: Fri, 28 Feb 2020 20:43:11 +0100 Subject: [PATCH 01/23] feat: add multi content editor --- .../netlify-cms-core/src/actions/config.js | 25 +++++++- .../src/components/Editor/Editor.js | 4 ++ .../Editor/EditorControlPane/EditorControl.js | 51 ++++++++++++++-- .../EditorControlPane/EditorControlPane.js | 24 +++++++- .../src/components/Editor/EditorInterface.js | 59 ++++++++++++++----- 5 files changed, 142 insertions(+), 21 deletions(-) diff --git a/packages/netlify-cms-core/src/actions/config.js b/packages/netlify-cms-core/src/actions/config.js index 646082670d79..2ed52ed30f6d 100644 --- a/packages/netlify-cms-core/src/actions/config.js +++ b/packages/netlify-cms-core/src/actions/config.js @@ -4,7 +4,7 @@ import { trimStart, trim, get, isPlainObject } from 'lodash'; import { authenticateUser } from 'Actions/auth'; import * as publishModes from 'Constants/publishModes'; import { validateConfig } from 'Constants/configSchema'; -import { selectDefaultSortableFields, traverseFields } from '../reducers/collections'; +import { selectDefaultSortableFields, traverseFields, selectIdentifier } from '../reducers/collections'; import { resolveBackend } from 'coreSrc/backend'; export const CONFIG_REQUEST = 'CONFIG_REQUEST'; @@ -63,6 +63,7 @@ export function applyDefaults(config) { map.setIn(['slug', 'sanitize_replacement'], '-'); } + const langs = map.get('locales'); // Strip leading slash from collection folders and files map.set( 'collections', @@ -98,6 +99,22 @@ export function applyDefaults(config) { } else { collection = collection.set('meta', Map()); } + + const fields = collection.get('fields'); + const identifier_field = selectIdentifier(collection); + if (langs && fields && collection.get('multi_content')) { + // add languague fields + collection = collection.set( + 'fields', + fromJS(addLanguageFields(fields.toJS(), langs.toJS())), + ); + + // add identifier field + collection = collection.set( + 'identifier_field', + `${langs.first()}.${identifier_field}`, + ); + } } const files = collection.get('files'); @@ -141,6 +158,12 @@ export function applyDefaults(config) { }); } +export function addLanguageFields(fields, langs) { + return langs.reduce((acc, item) => { + return [...acc, { label: item, name: item, widget: 'object', fields, multiContent: true }]; + }, []); +} + function mergePreloadedConfig(preloadedConfig, loadedConfig) { const map = fromJS(loadedConfig) || Map(); return preloadedConfig ? preloadedConfig.mergeDeep(map) : map; diff --git a/packages/netlify-cms-core/src/components/Editor/Editor.js b/packages/netlify-cms-core/src/components/Editor/Editor.js index 6dc5e8c121e2..89376a1e2241 100644 --- a/packages/netlify-cms-core/src/components/Editor/Editor.js +++ b/packages/netlify-cms-core/src/components/Editor/Editor.js @@ -367,6 +367,7 @@ export class Editor extends React.Component { loadDeployPreview, draftKey, slug, + locales, t, editorBackLink, } = this.props; @@ -416,6 +417,7 @@ export class Editor extends React.Component { currentStatus={currentStatus} onLogoutClick={logoutUser} deployPreview={deployPreview} + locales={locales} loadDeployPreview={opts => loadDeployPreview(collection, slug, entry, isPublished, opts)} editorBackLink={editorBackLink} /> @@ -444,6 +446,7 @@ function mapStateToProps(state, ownProps) { const deployPreview = selectDeployPreview(state, collectionName, slug); const localBackup = entryDraft.get('localBackup'); const draftKey = entryDraft.get('key'); + const locales = config.get('locales'); let editorBackLink = `/collections/${collectionName}`; if (collection.has('nested') && slug) { const pathParts = slug.split('/'); @@ -474,6 +477,7 @@ function mapStateToProps(state, ownProps) { publishedEntry, unPublishedEntry, editorBackLink, + locales, }; } diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js index 772577406cba..8a0514789720 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js @@ -7,7 +7,17 @@ import { ClassNames, Global, css as coreCss } from '@emotion/core'; import styled from '@emotion/styled'; import { partial, uniqueId } from 'lodash'; import { connect } from 'react-redux'; -import { FieldLabel, colors, transitions, lengths, borders } from 'netlify-cms-ui-default'; +import { + FieldLabel, + colors, + transitions, + lengths, + borders, + Dropdown, + DropdownItem, + StyledDropdownButton, + buttons, +} from 'netlify-cms-ui-default'; import { resolveWidget, getEditorComponents } from 'Lib/registry'; import { clearFieldErrors, tryLoadEntry } from 'Actions/entries'; import { addAsset, boundGetAsset } from 'Actions/media'; @@ -78,6 +88,20 @@ const ControlErrorsList = styled.ul` top: 20px; `; +const LocaleButton = styled(StyledDropdownButton)` + ${buttons.button}; + ${buttons.medium}; + color: ${colors.controlLabel}; + background: ${colors.textFieldBorder}; + height: 20px; + line-height: 28px; + margin-right: 8px; + + &:after { + top: 11px; + } +`; + export const ControlHint = styled.p` margin-bottom: 0; padding: 3px 0; @@ -173,6 +197,9 @@ class EditorControl extends React.Component { isEditorComponent, isNewEditorComponent, parentIds, + locales, + selectedLocale, + onLocaleChange, t, validateMetaField, } = this.props; @@ -187,6 +214,24 @@ class EditorControl extends React.Component { const errors = fieldsErrors && fieldsErrors.get(this.uniqueFieldId); const childErrors = this.isAncestorOfFieldError(); const hasErrors = !!errors || childErrors; + const multiContent = field.get('multiContent'); + const label = ( + <> + {locales && multiContent ? ( + {selectedLocale}} + dropdownTopOverlap="30px" + dropdownWidth="100px" + > + {locales.map(l => ( + onLocaleChange(l)} /> + ))} + + ) : ( + `${field.get('label', field.get('name'))}` + )} + + ); return ( @@ -211,9 +256,7 @@ class EditorControl extends React.Component { hasErrors={hasErrors} htmlFor={this.uniqueFieldId} > - {`${field.get('label', field.get('name'))}${ - isFieldOptional ? ` (${t('editor.editorControl.field.optional')})` : '' - }`} + {label} {`${isFieldOptional ? ` (${t('editor.editorControl.field.optional')})` : ''}`} { + let fields = this.props.fields; + if (this.props.collection.get('multi_content')) { + fields = fields.filter(f => f.get('name') === this.state.selectedLocale); + } + return fields; + }; + + handleLocaleChange = val => { + this.setState({ selectedLocale: val }); + }; + validate = () => { - this.props.fields.forEach(field => { + this.getFields().forEach(field => { if (field.get('widget') === 'hidden') return; this.componentValidate[field.get('name')](); }); @@ -32,13 +48,14 @@ export default class ControlPane extends React.Component { render() { const { collection, - fields, entry, fieldsMetaData, fieldsErrors, onChange, onValidate, + locales, } = this.props; + const fields = this.getFields(); if (!collection || !fields) { return null; @@ -68,6 +85,9 @@ export default class ControlPane extends React.Component { controlRef={this.controlRef} entry={entry} collection={collection} + selectedLocale={this.state.selectedLocale} + onLocaleChange={this.handleLocaleChange} + locales={locales} /> ); })} diff --git a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js index 53bab723069e..597ce3010efe 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js @@ -134,13 +134,13 @@ class EditorInterface extends Component { handleOnPersist = (opts = {}) => { const { createNew = false, duplicate = false } = opts; - this.controlPaneRef.validate(); + this.handleRefValidation(); this.props.onPersist({ createNew, duplicate }); }; handleOnPublish = (opts = {}) => { const { createNew = false, duplicate = false } = opts; - this.controlPaneRef.validate(); + this.handleRefValidation(); this.props.onPublish({ createNew, duplicate }); }; @@ -156,6 +156,10 @@ class EditorInterface extends Component { localStorage.setItem(SCROLL_SYNC_ENABLED, newScrollSyncEnabled); }; + handleRefValidation = () => { + [this.controlPaneRef, this.controlPaneRef2].filter(Boolean).forEach(c => c.validate()); + }; + render() { const { collection, @@ -186,24 +190,32 @@ class EditorInterface extends Component { deployPreview, draftKey, editorBackLink, + locales, } = this.props; const { previewVisible, scrollSyncEnabled, showEventBlocker } = this.state; - const collectionPreviewEnabled = collection.getIn(['editor', 'preview'], true); + const editorProps = { + collection, + entry, + fields, + fieldsMetaData, + fieldsErrors, + onChange, + onValidate, + locales, + }; + const multiContent = collection.get('multi_content'); const editor = ( - (this.controlPaneRef = c)} - /> + (this.controlPaneRef = c)} /> + + ); + + const editor2 = ( + + (this.controlPaneRef2 = c)} /> ); @@ -232,6 +244,23 @@ class EditorInterface extends Component { ); + const editorWithEditor = ( + +
+ localStorage.setItem(SPLIT_PANE_POSITION, size)} + onDragStarted={this.handleSplitPaneDragStart} + onDragFinished={this.handleSplitPaneDragFinished} + > + {editor} + {editor2} + +
+
+ ); + return ( )} - {collectionPreviewEnabled && this.state.previewVisible ? ( + {multiContent ? ( + editorWithEditor + ) : collectionPreviewEnabled && this.state.previewVisible ? ( editorWithPreview ) : ( {editor} From 0d4eb930bd86c49167f243a8d6f2f1c69e79ba45 Mon Sep 17 00:00:00 2001 From: barthc Date: Mon, 2 Mar 2020 20:53:04 +0100 Subject: [PATCH 02/23] feat: prepare backend for multi content --- packages/netlify-cms-core/package.json | 1 + .../netlify-cms-core/src/actions/config.js | 8 +- .../netlify-cms-core/src/actions/entries.ts | 29 ++- packages/netlify-cms-core/src/backend.ts | 216 +++++++++++++++--- .../Editor/EditorControlPane/EditorControl.js | 4 +- .../EditorControlPane/EditorControlPane.js | 4 +- .../src/components/Editor/EditorInterface.js | 14 +- .../src/constants/configSchema.js | 13 ++ .../src/valueObjects/Entry.ts | 6 + .../src/implementation.ts | 2 +- 10 files changed, 253 insertions(+), 44 deletions(-) diff --git a/packages/netlify-cms-core/package.json b/packages/netlify-cms-core/package.json index b0187fd23780..a3e692c01893 100644 --- a/packages/netlify-cms-core/package.json +++ b/packages/netlify-cms-core/package.json @@ -39,6 +39,7 @@ "immer": "^3.1.3", "js-base64": "^2.5.1", "jwt-decode": "^2.1.0", + "locale-codes": "^1.1.0", "node-polyglot": "^2.3.0", "prop-types": "^15.7.2", "react": "^16.8.4", diff --git a/packages/netlify-cms-core/src/actions/config.js b/packages/netlify-cms-core/src/actions/config.js index 2ed52ed30f6d..ce8e5814eda0 100644 --- a/packages/netlify-cms-core/src/actions/config.js +++ b/packages/netlify-cms-core/src/actions/config.js @@ -109,7 +109,11 @@ export function applyDefaults(config) { fromJS(addLanguageFields(fields.toJS(), langs.toJS())), ); - // add identifier field + // remove path for different folder config + if (collection.get('multi_content') === 'diff_folder') { + collection = collection.delete('path'); + } + // add identifier config collection = collection.set( 'identifier_field', `${langs.first()}.${identifier_field}`, @@ -119,6 +123,8 @@ export function applyDefaults(config) { const files = collection.get('files'); if (files) { + // remove multi_content config if set + collection = collection.delete('multi_content'); collection = collection.delete('nested'); collection = collection.delete('meta'); collection = collection.set( diff --git a/packages/netlify-cms-core/src/actions/entries.ts b/packages/netlify-cms-core/src/actions/entries.ts index de7df51509ee..62e35c408c38 100644 --- a/packages/netlify-cms-core/src/actions/entries.ts +++ b/packages/netlify-cms-core/src/actions/entries.ts @@ -454,11 +454,13 @@ export function deleteLocalBackup(collection: Collection, slug: string) { export function loadEntry(collection: Collection, slug: string) { return async (dispatch: ThunkDispatch, getState: () => State) => { + const locales = getState().config.get('locales'); + const multiContent = collection.get('multi_content'); await waitForMediaLibraryToLoad(dispatch, getState()); dispatch(entryLoading(collection, slug)); try { - const loadedEntry = await tryLoadEntry(getState(), collection, slug); + const loadedEntry = await tryLoadEntry(getState(), collection, slug, locales, multiContent); dispatch(entryLoaded(collection, loadedEntry)); dispatch(createDraftFromEntry(loadedEntry)); } catch (error) { @@ -478,9 +480,21 @@ export function loadEntry(collection: Collection, slug: string) { }; } -export async function tryLoadEntry(state: State, collection: Collection, slug: string) { +export async function tryLoadEntry( + state: State, + collection: Collection, + slug: string, + locales, + multiContent +) { const backend = currentBackend(state.config); - const loadedEntry = await backend.getEntry(state, collection, slug); + const loadedEntry = await backend.getEntry( + state, + collection, + slug, + locales, + multiContent + ); return loadedEntry; } @@ -511,12 +525,13 @@ export function loadEntries(collection: Collection, page = 0) { } const backend = currentBackend(state.config); + const locales = state.config.get('locales'); + const multiContent = collection.get('multi_content'); const integration = selectIntegration(state, collection.get('name'), 'listEntries'); const provider = integration ? getIntegrationProvider(state.integrations, backend.getToken, integration) : backend; const append = !!(page && !isNaN(page) && page > 0); - dispatch(entriesLoading(collection)); try { let response: { @@ -526,6 +541,8 @@ export function loadEntries(collection: Collection, page = 0) { } = await (collection.has('nested') ? // nested collections require all entries to construct the tree provider.listAllEntries(collection).then((entries: EntryValue[]) => ({ entries })) + : locales && ['same_folder', 'diff_folder'].includes(multiContent) + ? provider.listAllMultipleEntires(collection, locales) : provider.listEntries(collection, page)); response = { ...response, @@ -844,10 +861,12 @@ export function deleteEntry(collection: Collection, slug: string) { return (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const backend = currentBackend(state.config); + const locales = state.config.get('locales'); + const multiContent = collection.get('multi_content') === 'multiple_files'; dispatch(entryDeleting(collection, slug)); return backend - .deleteEntry(state, collection, slug) + .deleteEntry(state, collection, slug, locales, multiContent) .then(() => { return dispatch(entryDeleted(collection, slug)); }) diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index 00598c69e1da..cedc5eb511b4 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -1,4 +1,4 @@ -import { attempt, flatten, isError, uniq, trim, sortBy, get, set } from 'lodash'; +import { attempt, flatten, isError, uniq, trim, sortBy, groupBy, get, set } from 'lodash'; import { List, Map, fromJS, Set } from 'immutable'; import * as fuzzy from 'fuzzy'; import { resolveFormat } from './formats/formats'; @@ -439,20 +439,21 @@ export class Backend { return filteredEntries; } - async listEntries(collection: Collection) { + async listEntries(collection: Collection, depth: number) { const extension = selectFolderEntryExtension(collection); let listMethod: () => Promise; const collectionType = collection.get('type'); if (collectionType === FOLDER) { - listMethod = () => { - const depth = - collection.get('nested')?.get('depth') || - getPathDepth(collection.get('path', '') as string); + const selectedDepth = + depth || + collection.get('nested')?.get('depth') || + getPathDepth(collection.get('path', '') as string); + listMethod = () => { return this.implementation.entriesByFolder( collection.get('folder') as string, extension, - depth, + selectedDepth, ); }; } else if (collectionType === FILES) { @@ -491,19 +492,20 @@ export class Backend { // repeats the process. Once there is no available "next" action, it // returns all the collected entries. Used to retrieve all entries // for local searches and queries. - async listAllEntries(collection: Collection) { + async listAllEntries(collection: Collection, depth: number) { + const selectedDepth = + depth || + collection.get('nested')?.get('depth') || + getPathDepth(collection.get('path', '') as string); + if (collection.get('folder') && this.implementation.allEntriesByFolder) { const extension = selectFolderEntryExtension(collection); - const depth = - collection.get('nested')?.get('depth') || - getPathDepth(collection.get('path', '') as string); - return this.implementation - .allEntriesByFolder(collection.get('folder') as string, extension, depth) + .allEntriesByFolder(collection.get('folder') as string, extension, selectedDepth) .then(entries => this.processEntries(entries, collection)); } - const response = await this.listEntries(collection); + const response = await this.listEntries(collection, selectedDepth); const { entries } = response; let { cursor } = response; while (cursor && cursor.actions!.includes('next')) { @@ -514,6 +516,39 @@ export class Backend { return entries; } + async listAllMultipleEntires(collection: Collection, page: number, locales: string[]) { + const multiContent = collection.get('multi_content'); + const depth = multiContent === 'diff_folder' ? 2 : 0; + const entries = await this.listAllEntries(collection, depth); + let multiEntries; + if (multiContent === 'same_folder') { + multiEntries = entries + .filter(entry => locales.some(l => entry.slug.endsWith(`.${l}`))) + .map(entry => { + const path = entry.path.split('.'); + const locale = path.splice(-2, 2)[0]; + return { + ...entry, + slug: entry.slug.replace(`.${locale}`, ''), + multiContentKey: path.join('.'), + }; + }); + } else if (multiContent === 'diff_folder') { + multiEntries = entries + .filter(entry => locales.some(l => entry.slug.startsWith(`${l}/`))) + .map(entry => { + const path = entry.path.split('/'); + const locale = path.splice(-2, 1)[0]; + return { + ...entry, + slug: entry.slug.replace(`${locale}/`, ''), + multiContentKey: path.join('/'), + }; + }); + } + return { entries: this.combineMultiContentEntries(multiEntries, collection) }; + } + async search(collections: Collection[], searchTerm: string) { // Perform a local search by requesting all entries. For each // collection, load it, search, and call onCollectionResults with @@ -711,21 +746,61 @@ export class Backend { return localForage.removeItem(getEntryBackupKey()); } - async getEntry(state: State, collection: Collection, slug: string) { + async getEntry( + state: State, + collection: Collection, + slug: string, + locales: string[], + multiContent: string, + ) { const path = selectEntryPath(collection, slug) as string; const label = selectFileEntryLabel(collection, slug); + const extension = selectFolderEntryExtension(collection); + let loadedEntries; + let mediaFiles; + + if (locales && multiContent === 'same_folder') { + loadedEntries = await Promise.all( + locales.map(l => + this.implementation + .getEntry(path.replace(extension, `${l}.${extension}`)) + .catch(() => undefined), + ), + ); + } else if (locales && multiContent === 'diff_folder') { + loadedEntries = await Promise.all( + locales.map(l => + this.implementation + .getEntry(path.replace(`${slug}`, `${l}/${slug}`)) + .catch(() => undefined), + ), + ); + } else { + const loadedEntry = await this.implementation.getEntry(path); + loadedEntries = [loadedEntry]; + } - const loadedEntry = await this.implementation.getEntry(path); - let entry = createEntry(collection.get('name'), slug, loadedEntry.file.path, { - raw: loadedEntry.data, - label, - mediaFiles: [], - meta: { path: prepareMetaPath(loadedEntry.file.path, collection) }, - }); + const entries = await Promise.all( + loadedEntries.filter(Boolean).map(async loadedEntry => { + let entry = createEntry(collection.get('name'), slug, loadedEntry.file.path, { + raw: loadedEntry.data, + label, + mediaFiles: [], + meta: { path: prepareMetaPath(loadedEntry.file.path, collection) }, + }); - entry = this.entryWithFormat(collection)(entry); - entry = await this.processEntry(state, collection, entry); - return entry; + entry = this.entryWithFormat(collection)(entry); + entry = await this.processEntry(state, collection, entry); + + return entry; + }), + ); + + if (entries.length === 1) { + return entries[0]; + } + + return this.combineEntries(entries, multiContent); } getMedia() { @@ -874,6 +949,34 @@ export class Backend { return entry; } + combineMultiContentEntries(entries: entryMap[], collection: Collection) { + const groupEntries = groupBy(entries, 'multiContentKey'); + return Object.keys(groupEntries).reduce((acc, key) => { + const entries = groupEntries[key]; + const multiContent = + entries[0].multiContent || (collection && collection.get('multi_content')); + return [...acc, this.combineEntries(entries, multiContent)]; + }, []); + } + + combineEntries(entries: entryMap[], multiContent: string) { + const data = {}; + let splitChar; + let path; + if (multiContent == 'same_folder') { + splitChar = '.'; + } else if (multiContent == 'diff_folder') { + splitChar = '/'; + } + entries.forEach(e => { + const entryPath = e.path.split(splitChar); + const locale = entryPath.splice(-2, 1)[0]; + !path && (path = entryPath.join(splitChar)); + data[locale] = e.data; + }); + return { ...entries[0], path, raw: '', data }; + } + /** * Creates a URL using `site_url` from the config and `preview_path` from the * entry's collection. Does not currently make a request through the backend, @@ -961,6 +1064,7 @@ export class Backend { const entryDraft = (modifiedData && draft.setIn(['entry', 'data'], modifiedData)) || draft; const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; + const hasMultipleContent = collection.get('multi_content') && config.get('locales'); const useWorkflow = selectUseWorkflow(config); @@ -1014,14 +1118,46 @@ export class Backend { }; } + let entriesObj = [entryObj]; + if (hasMultipleContent) { + const multiContent = collection.get('multi_content'); + const extension = selectFolderEntryExtension(collection); + const data = entryDraft.getIn(['entry', 'data']).toJS(); + const locales = Object.keys(data); + entriesObj = []; + if (multiContent === 'same_folder') { + locales.forEach(l => { + entriesObj.push({ + path: entryObj.path.replace(extension, `${l}.${extension}`), + slug: entryObj.slug, + raw: this.entryToRaw( + collection, + entryDraft.get('entry').set('data', entryDraft.getIn(['entry', 'data', l])), + ), + }); + }); + } else if (multiContent === 'diff_folder') { + locales.forEach(l => { + entriesObj.push({ + path: entryObj.path.replace(`${entryObj.slug}`, `${l}/${entryObj.slug}`), + slug: entryObj.slug, + raw: this.entryToRaw( + collection, + entryDraft.get('entry').set('data', entryDraft.getIn(['entry', 'data', l])), + ), + }); + }); + } + } + const user = (await this.currentUser()) as User; const commitMessage = commitMessageFormatter( newEntry ? 'create' : 'update', config, { collection, - slug: entryObj.slug, - path: entryObj.path, + slug: entriesObj[0].slug, + path: entriesObj[0].path, authorLogin: user.login, authorName: user.name, }, @@ -1043,7 +1179,7 @@ export class Backend { await this.invokePrePublishEvent(entryDraft.get('entry')); } - await this.implementation.persistEntry(entryObj, assetProxies, opts); + await this.implementation.persistEntry(entriesObj, assetProxies, opts); await this.invokePostSaveEvent(entryDraft.get('entry')); @@ -1100,8 +1236,9 @@ export class Backend { return this.implementation.persistMedia(file, options); } - async deleteEntry(state: State, collection: Collection, slug: string) { + async deleteEntry(state: State, collection: Collection, slug: string, locales, multiContent) { const path = selectEntryPath(collection, slug) as string; + const extension = selectFolderEntryExtension(collection) as string; if (!selectAllowDeletion(collection)) { throw new Error('Not allowed to delete entries in this collection'); @@ -1122,9 +1259,28 @@ export class Backend { user.useOpenAuthoring, ); + let result; const entry = selectEntry(state.entries, collection.get('name'), slug); await this.invokePreUnpublishEvent(entry); - const result = await this.implementation.deleteFile(path, commitMessage); + if (locales && multiContent === 'same_folder') { + result = await Promise.all( + locales.map(l => + this.implementation + .deleteFile(path.replace(extension, `${l}.${extension}`)) + .catch(() => undefined), + ), + ); + } else if (locales && multiContent === 'diff_folder') { + result = await Promise.all( + locales.map(l => + this.implementation + .deleteFile(path.replace(`${slug}`, `${l}/${slug}`)) + .catch(() => undefined), + ), + ); + } else { + result = await this.implementation.deleteFile(path, commitMessage); + } await this.invokePostUnpublishEvent(entry); return result; } diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js index 8a0514789720..188e910fbc27 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js @@ -214,10 +214,10 @@ class EditorControl extends React.Component { const errors = fieldsErrors && fieldsErrors.get(this.uniqueFieldId); const childErrors = this.isAncestorOfFieldError(); const hasErrors = !!errors || childErrors; - const multiContent = field.get('multiContent'); + const multiContentWidget = field.get('multiContent'); const label = ( <> - {locales && multiContent ? ( + {locales && multiContentWidget ? ( {selectedLocale}} dropdownTopOverlap="30px" diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js index 91760d57bd80..43e38d5585ab 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js @@ -13,7 +13,7 @@ const ControlPaneContainer = styled.div` export default class ControlPane extends React.Component { state = { - selectedLocale: this.props.locales && this.props.locales.first(), + selectedLocale: this.props.locale, }; componentValidate = {}; @@ -28,7 +28,7 @@ export default class ControlPane extends React.Component { getFields = () => { let fields = this.props.fields; - if (this.props.collection.get('multi_content')) { + if (this.props.collection.get('multi_content') && this.props.locales) { fields = fields.filter(f => f.get('name') === this.state.selectedLocale); } return fields; diff --git a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js index 597ce3010efe..117f71e66c53 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js @@ -195,6 +195,7 @@ class EditorInterface extends Component { const { previewVisible, scrollSyncEnabled, showEventBlocker } = this.state; const collectionPreviewEnabled = collection.getIn(['editor', 'preview'], true); + const multiContent = collection.get('multi_content') && locales; const editorProps = { collection, entry, @@ -205,17 +206,24 @@ class EditorInterface extends Component { onValidate, locales, }; - const multiContent = collection.get('multi_content'); const editor = ( - (this.controlPaneRef = c)} /> + (this.controlPaneRef = c)} + locale={locales && locales.first()} + /> ); const editor2 = ( - (this.controlPaneRef2 = c)} /> + (this.controlPaneRef2 = c)} + locale={locales && locales.last()} + /> ); diff --git a/packages/netlify-cms-core/src/constants/configSchema.js b/packages/netlify-cms-core/src/constants/configSchema.js index b12698301469..8a5673af976c 100644 --- a/packages/netlify-cms-core/src/constants/configSchema.js +++ b/packages/netlify-cms-core/src/constants/configSchema.js @@ -1,9 +1,16 @@ import AJV from 'ajv'; import { select, uniqueItemProperties, instanceof as instanceOf } from 'ajv-keywords/keywords'; import ajvErrors from 'ajv-errors'; +import locale from 'locale-codes'; +import { uniq } from 'lodash'; import { formatExtensions, frontmatterFormats, extensionFormatters } from 'Formats/formats'; import { getWidgets } from 'Lib/registry'; +/** + * valid locales. + */ +const locales = uniq(locale.all.map(l => l['iso639-1']).filter(Boolean)); + /** * Config for fields in both file and folder collections. */ @@ -126,6 +133,11 @@ const getConfigSchema = () => ({ clean_accents: { type: 'boolean' }, }, }, + locales: { + type: 'array', + minItems: 2, + items: { type: 'string', enum: locales }, + }, collections: { type: 'array', minItems: 1, @@ -212,6 +224,7 @@ const getConfigSchema = () => ({ additionalProperties: false, minProperties: 1, }, + multi_content: { type: 'string', enum: ['single_file', 'same_folder', 'diff_folder'] }, }, required: ['name', 'label'], oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }], diff --git a/packages/netlify-cms-core/src/valueObjects/Entry.ts b/packages/netlify-cms-core/src/valueObjects/Entry.ts index 2894b109e3f5..8ec6c277a3cf 100644 --- a/packages/netlify-cms-core/src/valueObjects/Entry.ts +++ b/packages/netlify-cms-core/src/valueObjects/Entry.ts @@ -30,6 +30,8 @@ export interface EntryValue { updatedOn: string; status?: string; meta: { path?: string }; + multiContent?: string; + multiContentKey?: string; } export function createEntry(collection: string, slug = '', path = '', options: Options = {}) { @@ -47,6 +49,10 @@ export function createEntry(collection: string, slug = '', path = '', options: O updatedOn: options.updatedOn || '', status: options.status || '', meta: options.meta || {}, + ...(options.multiContentKey && { + multiContentKey: options.multiContentKey, + multiContent: options.multiContent, + }), }; return returnObj; diff --git a/packages/netlify-cms-lib-util/src/implementation.ts b/packages/netlify-cms-lib-util/src/implementation.ts index e6db07bf2e4e..f267179585dc 100644 --- a/packages/netlify-cms-lib-util/src/implementation.ts +++ b/packages/netlify-cms-lib-util/src/implementation.ts @@ -211,7 +211,7 @@ const fetchFiles = async ( ); }); return Promise.all(promises).then(loadedEntries => - loadedEntries.filter(loadedEntry => !(loadedEntry as { error: boolean }).error), + loadedEntries.flat().filter(loadedEntry => !(loadedEntry as { error: boolean }).error), ) as Promise; }; From 8b52a48521fda85e519dd31a496b58515d612472 Mon Sep 17 00:00:00 2001 From: barthc Date: Mon, 2 Mar 2020 20:56:52 +0100 Subject: [PATCH 03/23] feat(gitlab): support multi content --- packages/netlify-cms-backend-gitlab/src/API.ts | 6 +++--- packages/netlify-cms-backend-gitlab/src/implementation.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/netlify-cms-backend-gitlab/src/API.ts b/packages/netlify-cms-backend-gitlab/src/API.ts index 26ba77711e69..a2cbebfc46c3 100644 --- a/packages/netlify-cms-backend-gitlab/src/API.ts +++ b/packages/netlify-cms-backend-gitlab/src/API.ts @@ -512,10 +512,10 @@ export default class API { return items; } - async persistFiles(entry: Entry | null, mediaFiles: AssetProxy[], options: PersistOptions) { - const files = entry ? [entry, ...mediaFiles] : mediaFiles; + async persistFiles(entries: Entry[] | null, mediaFiles: AssetProxy[], options: PersistOptions) { + const files = entries ? [...entries, ...mediaFiles] : mediaFiles; if (options.useWorkflow) { - return this.editorialWorkflowGit(files, entry as Entry, options); + return this.editorialWorkflowGit(files, entries[0] as Entry, options); } else { const items = await this.getCommitItems(files, this.branch); return this.uploadAndCommit(items, { diff --git a/packages/netlify-cms-backend-gitlab/src/implementation.ts b/packages/netlify-cms-backend-gitlab/src/implementation.ts index ee9d4515636a..0e5d198c0dce 100644 --- a/packages/netlify-cms-backend-gitlab/src/implementation.ts +++ b/packages/netlify-cms-backend-gitlab/src/implementation.ts @@ -260,11 +260,11 @@ export default class GitLab implements Implementation { }; } - async persistEntry(entry: Entry, mediaFiles: AssetProxy[], options: PersistOptions) { + async persistEntry(entries: Entry[], mediaFiles: AssetProxy[], options: PersistOptions) { // persistEntry is a transactional operation return runWithLock( this.lock, - () => this.api!.persistFiles(entry, mediaFiles, options), + () => this.api!.persistFiles(entries, mediaFiles, options), 'Failed to acquire persist entry lock', ); } From 89d55f1e79476f31ada5ae8eba7b5c094f6973e4 Mon Sep 17 00:00:00 2001 From: barthc Date: Mon, 2 Mar 2020 22:35:02 +0100 Subject: [PATCH 04/23] feat(bitbucket): support multi content --- packages/netlify-cms-backend-bitbucket/src/API.ts | 6 +++--- .../src/implementation.ts | 4 ++-- packages/netlify-cms-backend-gitlab/src/API.ts | 15 +++++++++++++++ packages/netlify-cms-core/src/backend.ts | 6 ++++-- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/netlify-cms-backend-bitbucket/src/API.ts b/packages/netlify-cms-backend-bitbucket/src/API.ts index b814384b84fa..1f71fd7536df 100644 --- a/packages/netlify-cms-backend-bitbucket/src/API.ts +++ b/packages/netlify-cms-backend-bitbucket/src/API.ts @@ -499,10 +499,10 @@ export default class API { return files; } - async persistFiles(entry: Entry | null, mediaFiles: AssetProxy[], options: PersistOptions) { - const files = entry ? [entry, ...mediaFiles] : mediaFiles; + async persistFiles(entries: Entry[] | null, mediaFiles: AssetProxy[], options: PersistOptions) { + const files = entries ? [...entries, ...mediaFiles] : mediaFiles; if (options.useWorkflow) { - return this.editorialWorkflowGit(files, entry as Entry, options); + return this.editorialWorkflowGit(files, entries[0] as Entry, options); } else { return this.uploadFiles(files, { commitMessage: options.commitMessage, branch: this.branch }); } diff --git a/packages/netlify-cms-backend-bitbucket/src/implementation.ts b/packages/netlify-cms-backend-bitbucket/src/implementation.ts index d6b49f43079e..a9c721e2621e 100644 --- a/packages/netlify-cms-backend-bitbucket/src/implementation.ts +++ b/packages/netlify-cms-backend-bitbucket/src/implementation.ts @@ -428,14 +428,14 @@ export default class BitbucketBackend implements Implementation { }; } - async persistEntry(entry: Entry, mediaFiles: AssetProxy[], options: PersistOptions) { + async persistEntry(entries: Entry[], mediaFiles: AssetProxy[], options: PersistOptions) { const client = await this.getLargeMediaClient(); // persistEntry is a transactional operation return runWithLock( this.lock, async () => this.api!.persistFiles( - entry, + entries, client.enabled ? await getLargeMediaFilteredMediaFiles(client, mediaFiles) : mediaFiles, options, ), diff --git a/packages/netlify-cms-backend-gitlab/src/API.ts b/packages/netlify-cms-backend-gitlab/src/API.ts index a2cbebfc46c3..82bc88a7e1fb 100644 --- a/packages/netlify-cms-backend-gitlab/src/API.ts +++ b/packages/netlify-cms-backend-gitlab/src/API.ts @@ -650,6 +650,7 @@ export default class API { const { collection, slug } = parseContentKey(contentKey); const branch = branchFromContentKey(contentKey); const mergeRequest = await this.getBranchMergeRequest(branch); +<<<<<<< HEAD const diffs = await this.getDifferences(mergeRequest.sha); const diffsWithIds = await Promise.all( diffs.map(async d => { @@ -657,6 +658,20 @@ export default class API { const id = await this.getFileId(path, branch); return { id, path, newFile }; }), +======= + const diff = await this.getDifferences(mergeRequest.sha); + const entries = diff + .filter(d => !d.binary) + .map(d => ({ path: d.old_path, newFile: d.new_file })); + const mediaFiles = await Promise.all( + diff + .filter(d => d.binary) + .map(async d => { + const path = d.new_path; + const id = await this.getFileId(path, branch); + return { path, id }; + }), +>>>>>>> 022a9a3e... feat(bitbucket): support multi content ); const label = mergeRequest.labels.find(isCMSLabel) as string; const status = labelToStatus(label); diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index cedc5eb511b4..4122bf0b73e7 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -1064,7 +1064,9 @@ export class Backend { const entryDraft = (modifiedData && draft.setIn(['entry', 'data'], modifiedData)) || draft; const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; - const hasMultipleContent = collection.get('multi_content') && config.get('locales'); + const hasMultipleContent = + ['same_folder', 'diff_folder'].includes(collection.get('multi_content')) && + config.get('locales'); const useWorkflow = selectUseWorkflow(config); @@ -1117,7 +1119,7 @@ export class Backend { newPath: customPath, }; } - + console.log(entryObj); let entriesObj = [entryObj]; if (hasMultipleContent) { const multiContent = collection.get('multi_content'); From 3ae469ecb35ac3c93de4ebeb8cf42055980427e6 Mon Sep 17 00:00:00 2001 From: barthc Date: Tue, 3 Mar 2020 07:15:54 +0100 Subject: [PATCH 05/23] feat(github): add support for multi content --- packages/netlify-cms-backend-github/src/API.ts | 8 ++++---- .../src/implementation.tsx | 4 ++-- packages/netlify-cms-backend-gitlab/src/API.ts | 15 --------------- 3 files changed, 6 insertions(+), 21 deletions(-) diff --git a/packages/netlify-cms-backend-github/src/API.ts b/packages/netlify-cms-backend-github/src/API.ts index dc32a0d0d80f..1ee0e4ed1f47 100644 --- a/packages/netlify-cms-backend-github/src/API.ts +++ b/packages/netlify-cms-backend-github/src/API.ts @@ -1,6 +1,6 @@ import { Base64 } from 'js-base64'; import semaphore, { Semaphore } from 'semaphore'; -import { initial, last, partial, result, trimStart, trim } from 'lodash'; +import { initial, last, partial, result, trimStart, trim, difference } from 'lodash'; import { oneLine } from 'common-tags'; import { getAllResponses, @@ -870,8 +870,8 @@ export default class API { })); } - async persistFiles(entry: Entry | null, mediaFiles: AssetProxy[], options: PersistOptions) { - const files = entry ? mediaFiles.concat(entry) : mediaFiles; + async persistFiles(entries: Entry[] | null, mediaFiles: AssetProxy[], options: PersistOptions) { + const files = entries ? mediaFiles.concat(entries) : mediaFiles; const uploadPromises = files.map(file => this.uploadBlob(file)); await Promise.all(uploadPromises); @@ -891,7 +891,7 @@ export default class API { ); return this.editorialWorkflowGit( files as TreeFile[], - entry as Entry, + entries[0] as Entry, mediaFilesList, options, ); diff --git a/packages/netlify-cms-backend-github/src/implementation.tsx b/packages/netlify-cms-backend-github/src/implementation.tsx index 7a989a9bbf0c..f82c176f3e75 100644 --- a/packages/netlify-cms-backend-github/src/implementation.tsx +++ b/packages/netlify-cms-backend-github/src/implementation.tsx @@ -470,11 +470,11 @@ export default class GitHub implements Implementation { ); } - persistEntry(entry: Entry, mediaFiles: AssetProxy[] = [], options: PersistOptions) { + persistEntry(entries: Entry[], mediaFiles: AssetProxy[] = [], options: PersistOptions) { // persistEntry is a transactional operation return runWithLock( this.lock, - () => this.api!.persistFiles(entry, mediaFiles, options), + () => this.api!.persistFiles(entries, mediaFiles, options), 'Failed to acquire persist entry lock', ); } diff --git a/packages/netlify-cms-backend-gitlab/src/API.ts b/packages/netlify-cms-backend-gitlab/src/API.ts index 82bc88a7e1fb..a2cbebfc46c3 100644 --- a/packages/netlify-cms-backend-gitlab/src/API.ts +++ b/packages/netlify-cms-backend-gitlab/src/API.ts @@ -650,7 +650,6 @@ export default class API { const { collection, slug } = parseContentKey(contentKey); const branch = branchFromContentKey(contentKey); const mergeRequest = await this.getBranchMergeRequest(branch); -<<<<<<< HEAD const diffs = await this.getDifferences(mergeRequest.sha); const diffsWithIds = await Promise.all( diffs.map(async d => { @@ -658,20 +657,6 @@ export default class API { const id = await this.getFileId(path, branch); return { id, path, newFile }; }), -======= - const diff = await this.getDifferences(mergeRequest.sha); - const entries = diff - .filter(d => !d.binary) - .map(d => ({ path: d.old_path, newFile: d.new_file })); - const mediaFiles = await Promise.all( - diff - .filter(d => d.binary) - .map(async d => { - const path = d.new_path; - const id = await this.getFileId(path, branch); - return { path, id }; - }), ->>>>>>> 022a9a3e... feat(bitbucket): support multi content ); const label = mergeRequest.labels.find(isCMSLabel) as string; const status = labelToStatus(label); From dd7a3bb916d151bb19922882a6116c94ec2ecc92 Mon Sep 17 00:00:00 2001 From: barthc Date: Tue, 3 Mar 2020 11:55:39 +0100 Subject: [PATCH 06/23] chore: clean up --- .../netlify-cms-core/src/actions/config.js | 17 ++++++-- .../netlify-cms-core/src/actions/entries.ts | 6 ++- packages/netlify-cms-core/src/backend.ts | 39 +++++++++---------- .../Editor/EditorControlPane/EditorControl.js | 4 +- .../src/constants/configSchema.js | 4 +- .../src/constants/multiContentTypes.js | 4 ++ 6 files changed, 45 insertions(+), 29 deletions(-) create mode 100644 packages/netlify-cms-core/src/constants/multiContentTypes.js diff --git a/packages/netlify-cms-core/src/actions/config.js b/packages/netlify-cms-core/src/actions/config.js index ce8e5814eda0..53bf57a49270 100644 --- a/packages/netlify-cms-core/src/actions/config.js +++ b/packages/netlify-cms-core/src/actions/config.js @@ -6,6 +6,8 @@ import * as publishModes from 'Constants/publishModes'; import { validateConfig } from 'Constants/configSchema'; import { selectDefaultSortableFields, traverseFields, selectIdentifier } from '../reducers/collections'; import { resolveBackend } from 'coreSrc/backend'; +import { DIFF_FILE_TYPES } from 'Constants/multiContentTypes'; + export const CONFIG_REQUEST = 'CONFIG_REQUEST'; export const CONFIG_SUCCESS = 'CONFIG_SUCCESS'; @@ -109,8 +111,8 @@ export function applyDefaults(config) { fromJS(addLanguageFields(fields.toJS(), langs.toJS())), ); - // remove path for different folder config - if (collection.get('multi_content') === 'diff_folder') { + // remove path for same or different folder config + if (DIFF_FILE_TYPES.includes(collection.get('multi_content'))) { collection = collection.delete('path'); } // add identifier config @@ -166,7 +168,16 @@ export function applyDefaults(config) { export function addLanguageFields(fields, langs) { return langs.reduce((acc, item) => { - return [...acc, { label: item, name: item, widget: 'object', fields, multiContent: true }]; + return [ + ...acc, + { + label: item, + name: item, + widget: 'object', + fields, + multiContentId: Symbol.for('multiContentId'), + }, + ]; }, []); } diff --git a/packages/netlify-cms-core/src/actions/entries.ts b/packages/netlify-cms-core/src/actions/entries.ts index 62e35c408c38..644b59e274de 100644 --- a/packages/netlify-cms-core/src/actions/entries.ts +++ b/packages/netlify-cms-core/src/actions/entries.ts @@ -11,6 +11,7 @@ import { Cursor, ImplementationMediaFile } from 'netlify-cms-lib-util'; import { createEntry, EntryValue } from '../valueObjects/Entry'; import AssetProxy, { createAssetProxy } from '../valueObjects/AssetProxy'; import ValidationErrorTypes from '../constants/validationErrorTypes'; +import { DIFF_FILE_TYPES } from 'Constants/multiContentTypes'; import { addAssets, getAsset } from './media'; import { Collection, @@ -532,6 +533,7 @@ export function loadEntries(collection: Collection, page = 0) { ? getIntegrationProvider(state.integrations, backend.getToken, integration) : backend; const append = !!(page && !isNaN(page) && page > 0); + dispatch(entriesLoading(collection)); try { let response: { @@ -541,7 +543,7 @@ export function loadEntries(collection: Collection, page = 0) { } = await (collection.has('nested') ? // nested collections require all entries to construct the tree provider.listAllEntries(collection).then((entries: EntryValue[]) => ({ entries })) - : locales && ['same_folder', 'diff_folder'].includes(multiContent) + : locales && DIFF_FILE_TYPES.includes(multiContent) ? provider.listAllMultipleEntires(collection, locales) : provider.listEntries(collection, page)); response = { @@ -862,7 +864,7 @@ export function deleteEntry(collection: Collection, slug: string) { const state = getState(); const backend = currentBackend(state.config); const locales = state.config.get('locales'); - const multiContent = collection.get('multi_content') === 'multiple_files'; + const multiContent = DIFF_FILE_TYPES.includes(collection.get('multi_content')); dispatch(entryDeleting(collection, slug)); return backend diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index 4122bf0b73e7..098b86d9f06b 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -55,6 +55,7 @@ import { import AssetProxy from './valueObjects/AssetProxy'; import { FOLDER, FILES } from './constants/collectionTypes'; import { selectCustomPath } from './reducers/entryDraft'; +import { SAME_FOLDER, DIFF_FOLDER, DIFF_FILE_TYPES } from 'Constants/multiContentTypes'; const { extractTemplateVars, dateParsers, expandPath } = stringTemplate; @@ -518,10 +519,10 @@ export class Backend { async listAllMultipleEntires(collection: Collection, page: number, locales: string[]) { const multiContent = collection.get('multi_content'); - const depth = multiContent === 'diff_folder' ? 2 : 0; + const depth = multiContent === DIFF_FOLDER ? 2 : 0; const entries = await this.listAllEntries(collection, depth); let multiEntries; - if (multiContent === 'same_folder') { + if (multiContent === SAME_FOLDER) { multiEntries = entries .filter(entry => locales.some(l => entry.slug.endsWith(`.${l}`))) .map(entry => { @@ -533,7 +534,7 @@ export class Backend { multiContentKey: path.join('.'), }; }); - } else if (multiContent === 'diff_folder') { + } else if (multiContent === DIFF_FOLDER) { multiEntries = entries .filter(entry => locales.some(l => entry.slug.startsWith(`${l}/`))) .map(entry => { @@ -746,20 +747,17 @@ export class Backend { return localForage.removeItem(getEntryBackupKey()); } - async getEntry( - state: State, - collection: Collection, - slug: string, - locales: string[], - multiContent: string, - ) { + async getEntry(state: State, collection: Collection, slug: string) { const path = selectEntryPath(collection, slug) as string; const label = selectFileEntryLabel(collection, slug); const extension = selectFolderEntryExtension(collection); + const integration = selectIntegration(state.integrations, null, 'assetStore'); + const multiContent = collection.get('multi_content'); + const locales = state.config.get('locales'); let loadedEntries; let mediaFiles; - if (locales && multiContent === 'same_folder') { + if (locales && multiContent === SAME_FOLDER) { loadedEntries = await Promise.all( locales.map(l => this.implementation @@ -767,7 +765,7 @@ export class Backend { .catch(() => undefined), ), ); - } else if (locales && multiContent === 'diff_folder') { + } else if (locales && multiContent === DIFF_FOLDER) { loadedEntries = await Promise.all( locales.map(l => this.implementation @@ -963,9 +961,9 @@ export class Backend { const data = {}; let splitChar; let path; - if (multiContent == 'same_folder') { + if (multiContent === SAME_FOLDER) { splitChar = '.'; - } else if (multiContent == 'diff_folder') { + } else if (multiContent === DIFF_FOLDER) { splitChar = '/'; } entries.forEach(e => { @@ -1064,9 +1062,8 @@ export class Backend { const entryDraft = (modifiedData && draft.setIn(['entry', 'data'], modifiedData)) || draft; const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; - const hasMultipleContent = - ['same_folder', 'diff_folder'].includes(collection.get('multi_content')) && - config.get('locales'); + const MultiContentDiffFiles = + DIFF_FILE_TYPES.includes(collection.get('multi_content')) && config.get('locales'); const useWorkflow = selectUseWorkflow(config); @@ -1119,15 +1116,15 @@ export class Backend { newPath: customPath, }; } - console.log(entryObj); + let entriesObj = [entryObj]; - if (hasMultipleContent) { + if (MultiContentDiffFiles) { const multiContent = collection.get('multi_content'); const extension = selectFolderEntryExtension(collection); const data = entryDraft.getIn(['entry', 'data']).toJS(); const locales = Object.keys(data); entriesObj = []; - if (multiContent === 'same_folder') { + if (multiContent === SAME_FOLDER) { locales.forEach(l => { entriesObj.push({ path: entryObj.path.replace(extension, `${l}.${extension}`), @@ -1138,7 +1135,7 @@ export class Backend { ), }); }); - } else if (multiContent === 'diff_folder') { + } else if (multiContent === DIFF_FOLDER) { locales.forEach(l => { entriesObj.push({ path: entryObj.path.replace(`${entryObj.slug}`, `${l}/${entryObj.slug}`), diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js index 188e910fbc27..aa343f407688 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js @@ -214,10 +214,10 @@ class EditorControl extends React.Component { const errors = fieldsErrors && fieldsErrors.get(this.uniqueFieldId); const childErrors = this.isAncestorOfFieldError(); const hasErrors = !!errors || childErrors; - const multiContentWidget = field.get('multiContent'); + const multiContentWidgetId = field.get('multiContentId') === Symbol.for('multiContentId'); const label = ( <> - {locales && multiContentWidget ? ( + {locales && multiContentWidgetId ? ( {selectedLocale}} dropdownTopOverlap="30px" diff --git a/packages/netlify-cms-core/src/constants/configSchema.js b/packages/netlify-cms-core/src/constants/configSchema.js index 8a5673af976c..a6a7d7593a5d 100644 --- a/packages/netlify-cms-core/src/constants/configSchema.js +++ b/packages/netlify-cms-core/src/constants/configSchema.js @@ -5,6 +5,7 @@ import locale from 'locale-codes'; import { uniq } from 'lodash'; import { formatExtensions, frontmatterFormats, extensionFormatters } from 'Formats/formats'; import { getWidgets } from 'Lib/registry'; +import { SINGLE_FILE, SAME_FOLDER, DIFF_FOLDER } from 'Constants/multiContentTypes'; /** * valid locales. @@ -192,6 +193,7 @@ const getConfigSchema = () => ({ type: 'string', }, }, +<<<<<<< HEAD fields: fieldsConfig(), sortableFields: { type: 'array', @@ -224,7 +226,7 @@ const getConfigSchema = () => ({ additionalProperties: false, minProperties: 1, }, - multi_content: { type: 'string', enum: ['single_file', 'same_folder', 'diff_folder'] }, + multi_content: { type: 'string', enum: [SINGLE_FILE, SAME_FOLDER, DIFF_FOLDER] }, }, required: ['name', 'label'], oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }], diff --git a/packages/netlify-cms-core/src/constants/multiContentTypes.js b/packages/netlify-cms-core/src/constants/multiContentTypes.js new file mode 100644 index 000000000000..207863a1f2ee --- /dev/null +++ b/packages/netlify-cms-core/src/constants/multiContentTypes.js @@ -0,0 +1,4 @@ +export const SINGLE_FILE = 'single_file'; +export const SAME_FOLDER = 'same_folder'; +export const DIFF_FOLDER = 'diff_folder'; +export const DIFF_FILE_TYPES = [SAME_FOLDER, DIFF_FOLDER]; From ffa256f8ed28ac1ba34994998ce132d4e5f2fe71 Mon Sep 17 00:00:00 2001 From: barthc Date: Tue, 3 Mar 2020 12:28:09 +0100 Subject: [PATCH 07/23] fix: yarn.lock conflict --- yarn.lock | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/yarn.lock b/yarn.lock index 7bac8b2cc652..7661990eae88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10459,6 +10459,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= +iso639-codes@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/iso639-codes/-/iso639-codes-1.0.1.tgz#674a45cdabbfdf3719b8b971b93ca1eedbbb4a1d" + integrity sha512-jdTSv8yn6D7GODDrRtuWG7y3du3aoa+ki5H8h/Y48/NleNAd7Fw/M2niTTLXGH4QnqhJ98hg1JMQtP9csQ31Lg== + isobject@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" @@ -18409,6 +18414,11 @@ widest-line@^3.1.0: dependencies: string-width "^4.0.0" +windows-locale@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/windows-locale/-/windows-locale-1.0.1.tgz#b965309efddc48bf44912c8e596dd3796387e568" + integrity sha512-X8B22Cg9njwV4h3C5j28xmZ2eWaO69j63WhReeglB69LOT3LoqSO4Vb6TTVSfFikh4KQ9qBOJb6+WvR4tVLTfQ== + windows-release@^3.1.0: version "3.3.1" resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.3.1.tgz#cb4e80385f8550f709727287bf71035e209c4ace" From f2a18e2f2a09f96d1d463ba98181598e31c17c16 Mon Sep 17 00:00:00 2001 From: barthc Date: Wed, 4 Mar 2020 00:22:51 +0100 Subject: [PATCH 08/23] fix: add unit tests --- .../src/__tests__/API.spec.js | 2 +- .../src/__tests__/implementation.spec.js | 8 +- .../src/__tests__/backend.spec.js | 205 ++++++++++++++++++ .../src/actions/__tests__/config.spec.js | 37 ++++ .../netlify-cms-core/src/actions/config.js | 14 +- .../netlify-cms-core/src/actions/entries.ts | 4 +- packages/netlify-cms-core/src/backend.ts | 10 +- 7 files changed, 261 insertions(+), 19 deletions(-) diff --git a/packages/netlify-cms-backend-github/src/__tests__/API.spec.js b/packages/netlify-cms-backend-github/src/__tests__/API.spec.js index 25b4bb9b17e1..8eed28041d35 100644 --- a/packages/netlify-cms-backend-github/src/__tests__/API.spec.js +++ b/packages/netlify-cms-backend-github/src/__tests__/API.spec.js @@ -280,7 +280,7 @@ describe('github API', () => { }, ]; - await api.persistFiles(entry, mediaFiles, { useWorkflow: true }); + await api.persistFiles([entry], mediaFiles, { useWorkflow: true }); expect(api.uploadBlob).toHaveBeenCalledTimes(3); expect(api.uploadBlob).toHaveBeenCalledWith(entry); diff --git a/packages/netlify-cms-backend-test/src/__tests__/implementation.spec.js b/packages/netlify-cms-backend-test/src/__tests__/implementation.spec.js index 10e432be1a1a..aa62395893a3 100644 --- a/packages/netlify-cms-backend-test/src/__tests__/implementation.spec.js +++ b/packages/netlify-cms-backend-test/src/__tests__/implementation.spec.js @@ -52,7 +52,7 @@ describe('test backend implementation', () => { const backend = new TestBackend({}); const entry = { path: 'posts/some-post.md', raw: 'content', slug: 'some-post.md' }; - await backend.persistEntry(entry, [], { newEntry: true }); + await backend.persistEntry([entry], [], { newEntry: true }); expect(window.repoFiles).toEqual({ posts: { @@ -81,7 +81,7 @@ describe('test backend implementation', () => { const backend = new TestBackend({}); const entry = { path: 'posts/new-post.md', raw: 'content', slug: 'new-post.md' }; - await backend.persistEntry(entry, [], { newEntry: true }); + await backend.persistEntry([entry], [], { newEntry: true }); expect(window.repoFiles).toEqual({ pages: { @@ -109,7 +109,7 @@ describe('test backend implementation', () => { const slug = 'dir1/dir2/some-post.md'; const path = `posts/${slug}`; const entry = { path, raw: 'content', slug }; - await backend.persistEntry(entry, [], { newEntry: true }); + await backend.persistEntry([entry], [], { newEntry: true }); expect(window.repoFiles).toEqual({ posts: { @@ -144,7 +144,7 @@ describe('test backend implementation', () => { const slug = 'dir1/dir2/some-post.md'; const path = `posts/${slug}`; const entry = { path, raw: 'new content', slug }; - await backend.persistEntry(entry, [], { newEntry: false }); + await backend.persistEntry([entry], [], { newEntry: false }); expect(window.repoFiles).toEqual({ posts: { diff --git a/packages/netlify-cms-core/src/__tests__/backend.spec.js b/packages/netlify-cms-core/src/__tests__/backend.spec.js index 35445848a41b..dd9d519e3c41 100644 --- a/packages/netlify-cms-core/src/__tests__/backend.spec.js +++ b/packages/netlify-cms-core/src/__tests__/backend.spec.js @@ -940,4 +940,209 @@ describe('Backend', () => { ]); }); }); + + describe('combineMultipleContentEntries', () => { + const implementation = { + init: jest.fn(() => implementation), + }; + const config = Map({}); + const backend = new Backend(implementation, { config, backendName: 'github' }); + + it('should combine multiple content same folder entries', () => { + const entries = [ + { + path: 'posts/post.en.md', + data: { title: 'Title en', content: 'Content en' }, + i18nStructure: 'locale_file_extensions', + slugWithLocale: 'post.en', + }, + { + path: 'posts/post.fr.md', + data: { title: 'Title fr', content: 'Content fr' }, + i18nStructure: 'locale_file_extensions', + slugWithLocale: 'post.fr', + }, + ]; + + expect(backend.combineMultipleContentEntries(entries)).toEqual([ + { + path: 'posts/post.md', + raw: '', + data: { + en: { title: 'Title en', content: 'Content en' }, + fr: { title: 'Title fr', content: 'Content fr' }, + }, + }, + ]); + }); + + it('should combine multiple content different folder entries', () => { + const entries = [ + { + path: 'posts/en/post.md', + data: { title: 'Title en', content: 'Content en' }, + i18nStructure: 'locale_folders', + slugWithLocale: 'en/post.md', + }, + { + path: 'posts/fr/post.md', + data: { title: 'Title fr', content: 'Content fr' }, + i18nStructure: 'locale_folders', + slugWithLocale: 'fr/post.md', + }, + ]; + const collection = fromJS({ multi_content: 'diff_folder' }); + + expect(backend.combineMultipleContentEntries(entries, collection)).toEqual([ + { + path: 'posts/post.md', + raw: '', + data: { + en: { title: 'Title en', content: 'Content en' }, + fr: { title: 'Title fr', content: 'Content fr' }, + }, + }, + ]); + }); + }); + + describe('listAllMultipleEntires', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const implementation = { + init: jest.fn(() => implementation), + }; + const config = Map({}); + const backend = new Backend(implementation, { config, backendName: 'github' }); + + it('should combine multiple content same folder entries', async () => { + const entries = [ + { + slug: 'post.en', + path: 'posts/post.en.md', + data: { title: 'Title en', content: 'Content en' }, + }, + { + slug: 'post.fr', + path: 'posts/post.fr.md', + data: { title: 'Title fr', content: 'Content fr' }, + }, + ]; + const collection = fromJS({ + i18n_structure: 'locale_file_extensions', + locales: ['en', 'fr'], + }); + + backend.listAllEntries = jest.fn().mockResolvedValue(entries); + + await expect(backend.listAllMultipleEntires(collection)).resolves.toEqual({ + entries: [ + { + slug: 'post', + path: 'posts/post.md', + raw: '', + data: { + en: { title: 'Title en', content: 'Content en' }, + fr: { title: 'Title fr', content: 'Content fr' }, + }, + }, + ], + }); + }); + + it('should combine multiple content different folder entries', async () => { + const entries = [ + { + slug: 'en/post', + path: 'posts/en/post.md', + data: { title: 'Title en', content: 'Content en' }, + }, + { + slug: 'fr/post', + path: 'posts/fr/post.md', + data: { title: 'Title fr', content: 'Content fr' }, + }, + ]; + const collection = fromJS({ i18n_structure: 'locale_folders', locales: ['en', 'fr'] }); + + backend.listAllEntries = jest.fn().mockResolvedValue(entries); + + await expect(backend.listAllMultipleEntires(collection)).resolves.toEqual({ + entries: [ + { + slug: 'post', + path: 'posts/post.md', + raw: '', + data: { + en: { title: 'Title en', content: 'Content en' }, + fr: { title: 'Title fr', content: 'Content fr' }, + }, + }, + ], + }); + }); + }); + + describe('getMultipleEntries', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const implementation = { + init: jest.fn(() => implementation), + }; + const config = Map({}); + const backend = new Backend(implementation, { config, backendName: 'github' }); + const entryDraft = fromJS({ + entry: { + data: { + en: { title: 'post', content: 'Content en' }, + fr: { title: 'publier', content: 'Content fr' }, + }, + }, + }); + const entryObj = { path: 'posts/post.md', slug: 'post' }; + + it('should split multiple content into different locale file entries', async () => { + const collection = fromJS({ + i18n_structure: 'locale_file_extensions', + fields: [{ name: 'title' }, { name: 'content' }], + extension: 'md', + }); + + expect(backend.getMultipleEntries(collection, entryDraft, entryObj)).toEqual([ + { + slug: 'post', + path: 'posts/post.en.md', + raw: '---\ntitle: post\ncontent: Content en\n---\n', + }, + { + slug: 'post', + path: 'posts/post.fr.md', + raw: '---\ntitle: publier\ncontent: Content fr\n---\n', + }, + ]); + }); + + it('should split multiple content into different locale folder entries', async () => { + const collection = fromJS({ + i18n_structure: 'locale_folders', + fields: [{ name: 'title' }, { name: 'content' }], + extension: 'md', + }); + + expect(backend.getMultipleEntries(collection, entryDraft, entryObj)).toEqual([ + { + slug: 'post', + path: 'posts/en/post.md', + raw: '---\ntitle: post\ncontent: Content en\n---\n', + }, + { + slug: 'post', + path: 'posts/fr/post.md', + raw: '---\ntitle: publier\ncontent: Content fr\n---\n', + }, + ]); + }); + }); }); 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 7157b2d7a8b6..41459ee4eb38 100644 --- a/packages/netlify-cms-core/src/actions/__tests__/config.spec.js +++ b/packages/netlify-cms-core/src/actions/__tests__/config.spec.js @@ -1,6 +1,10 @@ import { fromJS } from 'immutable'; +<<<<<<< HEAD import { stripIndent } from 'common-tags'; import { parseConfig, applyDefaults, detectProxyServer, handleLocalBackend } from '../config'; +======= +import { applyDefaults, detectProxyServer, handleLocalBackend, addLocaleFields } from '../config'; +>>>>>>> 6f4b539c... fix: add unit tests jest.spyOn(console, 'log').mockImplementation(() => {}); jest.mock('coreSrc/backend', () => { @@ -574,4 +578,37 @@ describe('config', () => { ); }); }); + + describe('addLocaleFields', () => { + it('should add locale fields', () => { + const fields = [ + { name: 'title', widget: 'string' }, + { name: 'content', widget: 'markdown' }, + ]; + const actual = addLocaleFields(fields, ['en', 'fr']); + + expect(actual).toEqual([ + { + label: 'en', + name: 'en', + widget: 'object', + multiContentId: Symbol.for('multiContentId'), + fields: [ + { name: 'title', widget: 'string' }, + { name: 'content', widget: 'markdown' }, + ], + }, + { + label: 'fr', + name: 'fr', + widget: 'object', + multiContentId: Symbol.for('multiContentId'), + fields: [ + { name: 'title', widget: 'string' }, + { name: 'content', widget: 'markdown' }, + ], + }, + ]); + }); + }); }); diff --git a/packages/netlify-cms-core/src/actions/config.js b/packages/netlify-cms-core/src/actions/config.js index 53bf57a49270..b2f35292aff5 100644 --- a/packages/netlify-cms-core/src/actions/config.js +++ b/packages/netlify-cms-core/src/actions/config.js @@ -65,7 +65,7 @@ export function applyDefaults(config) { map.setIn(['slug', 'sanitize_replacement'], '-'); } - const langs = map.get('locales'); + const locales = map.get('locales'); // Strip leading slash from collection folders and files map.set( 'collections', @@ -104,11 +104,11 @@ export function applyDefaults(config) { const fields = collection.get('fields'); const identifier_field = selectIdentifier(collection); - if (langs && fields && collection.get('multi_content')) { - // add languague fields + if (locales && fields && collection.get('multi_content')) { + // add locale fields collection = collection.set( 'fields', - fromJS(addLanguageFields(fields.toJS(), langs.toJS())), + fromJS(addLocaleFields(fields.toJS(), locales.toJS())), ); // remove path for same or different folder config @@ -118,7 +118,7 @@ export function applyDefaults(config) { // add identifier config collection = collection.set( 'identifier_field', - `${langs.first()}.${identifier_field}`, + `${locales.first()}.${identifier_field}`, ); } } @@ -166,8 +166,8 @@ export function applyDefaults(config) { }); } -export function addLanguageFields(fields, langs) { - return langs.reduce((acc, item) => { +export function addLocaleFields(fields, locales) { + return locales.reduce((acc, item) => { return [ ...acc, { diff --git a/packages/netlify-cms-core/src/actions/entries.ts b/packages/netlify-cms-core/src/actions/entries.ts index 644b59e274de..e71f7afbf498 100644 --- a/packages/netlify-cms-core/src/actions/entries.ts +++ b/packages/netlify-cms-core/src/actions/entries.ts @@ -863,12 +863,10 @@ export function deleteEntry(collection: Collection, slug: string) { return (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const backend = currentBackend(state.config); - const locales = state.config.get('locales'); - const multiContent = DIFF_FILE_TYPES.includes(collection.get('multi_content')); dispatch(entryDeleting(collection, slug)); return backend - .deleteEntry(state, collection, slug, locales, multiContent) + .deleteEntry(state, collection, slug) .then(() => { return dispatch(entryDeleted(collection, slug)); }) diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index 098b86d9f06b..8f9efe3c7f90 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -1235,15 +1235,17 @@ export class Backend { return this.implementation.persistMedia(file, options); } - async deleteEntry(state: State, collection: Collection, slug: string, locales, multiContent) { + async deleteEntry(state: State, collection: Collection, slug: string) { + const config = state.config; const path = selectEntryPath(collection, slug) as string; const extension = selectFolderEntryExtension(collection) as string; + const multiContent = DIFF_FILE_TYPES.includes(collection.get('multi_content')); + const locales = config.get('locales'); if (!selectAllowDeletion(collection)) { throw new Error('Not allowed to delete entries in this collection'); } - const config = state.config; const user = (await this.currentUser()) as User; const commitMessage = commitMessageFormatter( 'delete', @@ -1261,7 +1263,7 @@ export class Backend { let result; const entry = selectEntry(state.entries, collection.get('name'), slug); await this.invokePreUnpublishEvent(entry); - if (locales && multiContent === 'same_folder') { + if (locales && multiContent === SAME_FOLDER) { result = await Promise.all( locales.map(l => this.implementation @@ -1269,7 +1271,7 @@ export class Backend { .catch(() => undefined), ), ); - } else if (locales && multiContent === 'diff_folder') { + } else if (locales && multiContent === DIFF_FOLDER) { result = await Promise.all( locales.map(l => this.implementation From ab60daabacdb2bf65784f476dbc09ded0af223b9 Mon Sep 17 00:00:00 2001 From: barthc Date: Wed, 4 Mar 2020 09:39:56 +0100 Subject: [PATCH 09/23] fix: delete multiple content --- packages/netlify-cms-core/src/backend.ts | 39 ++++++++++++------------ 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index 8f9efe3c7f90..6ffff8776cf0 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -1239,7 +1239,8 @@ export class Backend { const config = state.config; const path = selectEntryPath(collection, slug) as string; const extension = selectFolderEntryExtension(collection) as string; - const multiContent = DIFF_FILE_TYPES.includes(collection.get('multi_content')); + const MultiContentDiffFiles = + DIFF_FILE_TYPES.includes(collection.get('multi_content')) && config.get('locales'); const locales = config.get('locales'); if (!selectAllowDeletion(collection)) { @@ -1260,30 +1261,28 @@ export class Backend { user.useOpenAuthoring, ); - let result; const entry = selectEntry(state.entries, collection.get('name'), slug); await this.invokePreUnpublishEvent(entry); - if (locales && multiContent === SAME_FOLDER) { - result = await Promise.all( - locales.map(l => - this.implementation - .deleteFile(path.replace(extension, `${l}.${extension}`)) - .catch(() => undefined), - ), - ); - } else if (locales && multiContent === DIFF_FOLDER) { - result = await Promise.all( - locales.map(l => - this.implementation - .deleteFile(path.replace(`${slug}`, `${l}/${slug}`)) - .catch(() => undefined), - ), - ); + if (MultiContentDiffFiles) { + const multiContent = collection.get('multi_content'); + if (multiContent === SAME_FOLDER) { + for (const l of locales) { + await this.implementation + .deleteFile(path.replace(extension, `${l}.${extension}`), commitMessage) + .catch(() => undefined); + } + } else if (multiContent === DIFF_FOLDER) { + for (const l of locales) { + await this.implementation + .deleteFile(path.replace(`${slug}`, `${l}/${slug}`), commitMessage) + .catch(() => undefined); + } + } } else { - result = await this.implementation.deleteFile(path, commitMessage); + await this.implementation.deleteFile(path, commitMessage); } await this.invokePostUnpublishEvent(entry); - return result; + return Promise.resolve(); } async deleteMedia(config: Config, path: string) { From fbd67439d36d7f83af3966d1018ea0245cebe2d8 Mon Sep 17 00:00:00 2001 From: barthc Date: Wed, 4 Mar 2020 14:13:22 +0100 Subject: [PATCH 10/23] fix: handle single file changes --- packages/netlify-cms-core/src/valueObjects/Entry.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/netlify-cms-core/src/valueObjects/Entry.ts b/packages/netlify-cms-core/src/valueObjects/Entry.ts index 8ec6c277a3cf..e08eb66b5ec5 100644 --- a/packages/netlify-cms-core/src/valueObjects/Entry.ts +++ b/packages/netlify-cms-core/src/valueObjects/Entry.ts @@ -51,6 +51,8 @@ export function createEntry(collection: string, slug = '', path = '', options: O meta: options.meta || {}, ...(options.multiContentKey && { multiContentKey: options.multiContentKey, + }), + ...(options.multiContent && { multiContent: options.multiContent, }), }; From 847d234896062e6af49661512e4e565c380dbbcd Mon Sep 17 00:00:00 2001 From: barthc Date: Mon, 9 Mar 2020 17:43:30 +0100 Subject: [PATCH 11/23] fix: feedback changes --- packages/netlify-cms-core/package.json | 1 - .../src/__tests__/backend.spec.js | 2 +- .../src/actions/__tests__/config.spec.js | 52 +++---- .../netlify-cms-core/src/actions/config.js | 77 ++++++---- .../src/actions/editorialWorkflow.ts | 21 ++- .../netlify-cms-core/src/actions/entries.ts | 1 + packages/netlify-cms-core/src/backend.ts | 94 +++++++------ .../src/components/Editor/Editor.js | 4 - .../Editor/EditorControlPane/EditorControl.js | 1 + .../EditorControlPane/EditorControlPane.js | 34 +++-- .../src/components/Editor/EditorInterface.js | 20 ++- .../src/constants/configSchema.js | 43 +++--- .../src/constants/multiContentTypes.js | 131 +++++++++++++++++- .../src/valueObjects/Entry.ts | 12 +- .../src/MarkdownControl/RawEditor.js | 11 +- .../src/MarkdownControl/VisualEditor.js | 12 +- yarn.lock | 10 -- 17 files changed, 360 insertions(+), 166 deletions(-) diff --git a/packages/netlify-cms-core/package.json b/packages/netlify-cms-core/package.json index a3e692c01893..b0187fd23780 100644 --- a/packages/netlify-cms-core/package.json +++ b/packages/netlify-cms-core/package.json @@ -39,7 +39,6 @@ "immer": "^3.1.3", "js-base64": "^2.5.1", "jwt-decode": "^2.1.0", - "locale-codes": "^1.1.0", "node-polyglot": "^2.3.0", "prop-types": "^15.7.2", "react": "^16.8.4", diff --git a/packages/netlify-cms-core/src/__tests__/backend.spec.js b/packages/netlify-cms-core/src/__tests__/backend.spec.js index dd9d519e3c41..100194da8b1f 100644 --- a/packages/netlify-cms-core/src/__tests__/backend.spec.js +++ b/packages/netlify-cms-core/src/__tests__/backend.spec.js @@ -941,7 +941,7 @@ describe('Backend', () => { }); }); - describe('combineMultipleContentEntries', () => { + describe('combineMultipleContentEntries', () => { const implementation = { init: jest.fn(() => implementation), }; 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 41459ee4eb38..f7b5595c1129 100644 --- a/packages/netlify-cms-core/src/actions/__tests__/config.spec.js +++ b/packages/netlify-cms-core/src/actions/__tests__/config.spec.js @@ -581,34 +581,38 @@ describe('config', () => { describe('addLocaleFields', () => { it('should add locale fields', () => { - const fields = [ + const fields = fromJS([ { name: 'title', widget: 'string' }, + { name: 'date', widget: 'date' }, { name: 'content', widget: 'markdown' }, - ]; + ]); const actual = addLocaleFields(fields, ['en', 'fr']); - expect(actual).toEqual([ - { - label: 'en', - name: 'en', - widget: 'object', - multiContentId: Symbol.for('multiContentId'), - fields: [ - { name: 'title', widget: 'string' }, - { name: 'content', widget: 'markdown' }, - ], - }, - { - label: 'fr', - name: 'fr', - widget: 'object', - multiContentId: Symbol.for('multiContentId'), - fields: [ - { name: 'title', widget: 'string' }, - { name: 'content', widget: 'markdown' }, - ], - }, - ]); + expect(actual).toEqual( + fromJS([ + { + label: 'en', + name: 'en', + widget: 'object', + multiContentId: Symbol.for('multiContentId'), + fields: [ + { name: 'title', widget: 'string' }, + { name: 'date', widget: 'date' }, + { name: 'content', widget: 'markdown' }, + ], + }, + { + label: 'fr', + name: 'fr', + widget: 'object', + multiContentId: Symbol.for('multiContentId'), + fields: [ + { name: 'title', widget: 'string' }, + { name: 'content', widget: 'markdown' }, + ], + }, + ]), + ); }); }); }); diff --git a/packages/netlify-cms-core/src/actions/config.js b/packages/netlify-cms-core/src/actions/config.js index b2f35292aff5..0e2cdfd9678a 100644 --- a/packages/netlify-cms-core/src/actions/config.js +++ b/packages/netlify-cms-core/src/actions/config.js @@ -5,9 +5,7 @@ import { authenticateUser } from 'Actions/auth'; import * as publishModes from 'Constants/publishModes'; import { validateConfig } from 'Constants/configSchema'; import { selectDefaultSortableFields, traverseFields, selectIdentifier } from '../reducers/collections'; -import { resolveBackend } from 'coreSrc/backend'; -import { DIFF_FILE_TYPES } from 'Constants/multiContentTypes'; - +import { NON_TRANSLATABLE_FIELDS } from 'Constants/multiContentTypes'; export const CONFIG_REQUEST = 'CONFIG_REQUEST'; export const CONFIG_SUCCESS = 'CONFIG_SUCCESS'; @@ -65,7 +63,8 @@ export function applyDefaults(config) { map.setIn(['slug', 'sanitize_replacement'], '-'); } - const locales = map.get('locales'); + const backend = map.getIn(['backend', 'name']); + let locales = map.get('locales', List()).toJS(); // Strip leading slash from collection folders and files map.set( 'collections', @@ -104,22 +103,27 @@ export function applyDefaults(config) { const fields = collection.get('fields'); const identifier_field = selectIdentifier(collection); - if (locales && fields && collection.get('multi_content')) { - // add locale fields - collection = collection.set( - 'fields', - fromJS(addLocaleFields(fields.toJS(), locales.toJS())), - ); - - // remove path for same or different folder config - if (DIFF_FILE_TYPES.includes(collection.get('multi_content'))) { - collection = collection.delete('path'); + if (!isEmpty(locales) && fields && collection.get('i18n_structure')) { + if (!collection.get('default_locale')) { + collection = collection.set('default_locale', locales[0]); + } else { + locales = uniq([collection.get('default_locale'), ...locales]); } + // add identifier config - collection = collection.set( - 'identifier_field', - `${locales.first()}.${identifier_field}`, - ); + collection = collection.set('identifier_field', `${locales[0]}.${identifier_field}`); + + collection = collection.set('locales', fromJS(locales)); + + // add locale fields + collection = collection.set('fields', addLocaleFields(fields, locales)); + + collection = collection.set('multi_content', true); + + // for test-repo backend single file mode should suffice + if (backend === 'test-repo') { + collection = collection.set('i18n_structure', 'single_file'); + } } } @@ -167,18 +171,41 @@ export function applyDefaults(config) { } export function addLocaleFields(fields, locales) { + const defaultLocale = locales[0]; + const stripedFields = stripNonTranslatableFields(fields); return locales.reduce((acc, item) => { - return [ - ...acc, - { + const selectedFields = item === defaultLocale ? fields : stripedFields; + return acc.push( + fromJS({ label: item, name: item, widget: 'object', - fields, + fields: selectedFields, multiContentId: Symbol.for('multiContentId'), - }, - ]; - }, []); + }), + ); + }, List()); +} + +function stripNonTranslatableFields(fields) { + return fields.reduce((acc, item) => { + const subfields = item.get('field') || item.get('fields'); + const widget = item.get('widget'); + + if (List.isList(subfields)) { + return acc.push(item.set('fields', stripNonTranslatableFields(subfields))); + } + + if (Map.isMap(subfields)) { + return acc.push(item.set('field', stripNonTranslatableFields([subfields]))); + } + + if (!NON_TRANSLATABLE_FIELDS.includes(widget)) { + return acc.push(item); + } + + return acc; + }, List()); } function mergePreloadedConfig(preloadedConfig, loadedConfig) { diff --git a/packages/netlify-cms-core/src/actions/editorialWorkflow.ts b/packages/netlify-cms-core/src/actions/editorialWorkflow.ts index f4c0571ef775..b8d09d36c5f7 100644 --- a/packages/netlify-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/netlify-cms-core/src/actions/editorialWorkflow.ts @@ -3,7 +3,7 @@ import { get } from 'lodash'; import { actions as notifActions } from 'redux-notifications'; import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; import { ThunkDispatch } from 'redux-thunk'; -import { Map, List } from 'immutable'; +import { Map, List, fromJS } from 'immutable'; import { serializeValues } from '../lib/serializeEntryValues'; import { currentBackend, slugFromCustomPath } from '../backend'; import { @@ -23,6 +23,7 @@ import { createDraftFromEntry, loadEntries, } from './entries'; +import { DIFF_FILE_TYPES } from 'Constants/multiContentTypes'; import { createAssetProxy } from '../valueObjects/AssetProxy'; import { addAssets } from './media'; import { loadMedia } from './mediaLibrary'; @@ -266,6 +267,9 @@ export function loadUnpublishedEntry(collection: Collection, slug: string) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const backend = currentBackend(state.config); + const locales = state.config.get('locales'); + const multiContent = collection.get('multi_content'); + const i18nStructure = collection.get('i18n_structure'); const entriesLoaded = get(state.editorialWorkflow.toJS(), 'pages.ids', false); //run possible unpublishedEntries migration if (!entriesLoaded) { @@ -292,6 +296,21 @@ export function loadUnpublishedEntry(collection: Collection, slug: string) { ), ); dispatch(addAssets(assetProxies)); + + if (multiContent && DIFF_FILE_TYPES.includes(i18nStructure)) { + const publishedEntries = get( + getState().entries.toJS(), + `pages.${collection.get('name')}.ids`, + false, + ); + !publishedEntries && (await dispatch(loadEntry(collection, slug))); + + if (entry.isModification) { + const publishedEntry = selectEntry(getState(), collection.get('name'), slug); + entry = publishedEntry.mergeDeep(fromJS(entry)).toJS(); + } + } + dispatch(unpublishedEntryLoaded(collection, entry)); dispatch(createDraftFromEntry(entry)); } catch (error) { diff --git a/packages/netlify-cms-core/src/actions/entries.ts b/packages/netlify-cms-core/src/actions/entries.ts index e71f7afbf498..03efc4b189df 100644 --- a/packages/netlify-cms-core/src/actions/entries.ts +++ b/packages/netlify-cms-core/src/actions/entries.ts @@ -528,6 +528,7 @@ export function loadEntries(collection: Collection, page = 0) { const backend = currentBackend(state.config); const locales = state.config.get('locales'); const multiContent = collection.get('multi_content'); + const i18nStructure = collection.get('i18n_structure'); const integration = selectIntegration(state, collection.get('name'), 'listEntries'); const provider = integration ? getIntegrationProvider(state.integrations, backend.getToken, integration) diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index 6ffff8776cf0..0cb1c35b29b4 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -55,7 +55,7 @@ import { import AssetProxy from './valueObjects/AssetProxy'; import { FOLDER, FILES } from './constants/collectionTypes'; import { selectCustomPath } from './reducers/entryDraft'; -import { SAME_FOLDER, DIFF_FOLDER, DIFF_FILE_TYPES } from 'Constants/multiContentTypes'; +import { LOCALE_FILE_EXTENSIONS, LOCALE_FOLDERS } from './constants/multiContentTypes'; const { extractTemplateVars, dateParsers, expandPath } = stringTemplate; @@ -518,11 +518,11 @@ export class Backend { } async listAllMultipleEntires(collection: Collection, page: number, locales: string[]) { - const multiContent = collection.get('multi_content'); - const depth = multiContent === DIFF_FOLDER ? 2 : 0; + const i18nStructure = collection.get('i18n_structure'); + const depth = i18nStructure === LOCALE_FOLDERS ? 2 : ''; const entries = await this.listAllEntries(collection, depth); let multiEntries; - if (multiContent === SAME_FOLDER) { + if (i18nStructure === LOCALE_FILE_EXTENSIONS) { multiEntries = entries .filter(entry => locales.some(l => entry.slug.endsWith(`.${l}`))) .map(entry => { @@ -531,23 +531,26 @@ export class Backend { return { ...entry, slug: entry.slug.replace(`.${locale}`, ''), - multiContentKey: path.join('.'), + contentKey: path.join('.'), + i18nStructure, + slugWithLocale: entry.slug, }; }); - } else if (multiContent === DIFF_FOLDER) { + } else if (i18nStructure === LOCALE_FOLDERS) { multiEntries = entries .filter(entry => locales.some(l => entry.slug.startsWith(`${l}/`))) .map(entry => { - const path = entry.path.split('/'); - const locale = path.splice(-2, 1)[0]; + const locale = entry.slug.slice(0, 2); return { ...entry, slug: entry.slug.replace(`${locale}/`, ''), - multiContentKey: path.join('/'), + contentKey: entry.path.replace(`${locale}/`, ''), + i18nStructure, + slugWithLocale: entry.slug, }; }); } - return { entries: this.combineMultiContentEntries(multiEntries, collection) }; + return { entries: this.combineMultipleContentEntries(multiEntries, collection) }; } async search(collections: Collection[], searchTerm: string) { @@ -747,17 +750,16 @@ export class Backend { return localForage.removeItem(getEntryBackupKey()); } - async getEntry(state: State, collection: Collection, slug: string) { +async getEntry(state: State, collection: Collection, slug: string) { const path = selectEntryPath(collection, slug) as string; const label = selectFileEntryLabel(collection, slug); const extension = selectFolderEntryExtension(collection); - const integration = selectIntegration(state.integrations, null, 'assetStore'); const multiContent = collection.get('multi_content'); - const locales = state.config.get('locales'); + const i18nStructure = collection.get('i18n_structure'); + const locales = collection.get('locales') as string[]; let loadedEntries; - let mediaFiles; - if (locales && multiContent === SAME_FOLDER) { + if (multiContent && i18nStructure === LOCALE_FILE_EXTENSIONS) { loadedEntries = await Promise.all( locales.map(l => this.implementation @@ -765,11 +767,11 @@ export class Backend { .catch(() => undefined), ), ); - } else if (locales && multiContent === DIFF_FOLDER) { + } else if (multiContent && i18nStructure === LOCALE_FOLDERS) { loadedEntries = await Promise.all( locales.map(l => this.implementation - .getEntry(path.replace(`${slug}`, `${l}/${slug}`)) + .getEntry(path.replace(`/${slug}`, `/${l}/${slug}`)) .catch(() => undefined), ), ); @@ -779,10 +781,12 @@ export class Backend { } const entries = await Promise.all( - loadedEntries.filter(Boolean).map(async loadedEntry => { + loadedEntries.filter(Boolean).map(async (loadedEntry: any) => { let entry = createEntry(collection.get('name'), slug, loadedEntry.file.path, { raw: loadedEntry.data, label, + i18nStructure, + slugWithLocale: selectEntrySlug(collection, loadedEntry.file.path), mediaFiles: [], meta: { path: prepareMetaPath(loadedEntry.file.path, collection) }, }); @@ -794,11 +798,11 @@ export class Backend { }), ); - if (entries.length === 1) { - return entries[0]; + if (collection.get('multi_content_diff_files')) { + return this.combineEntries(entries); } - return this.combineEntries(entries, multiContent); + return entries[0]; } getMedia() { @@ -947,32 +951,32 @@ export class Backend { return entry; } - combineMultiContentEntries(entries: entryMap[], collection: Collection) { - const groupEntries = groupBy(entries, 'multiContentKey'); + combineMultipleContentEntries(entries: entryMap[]) { + const groupEntries = groupBy(entries, 'contentKey'); return Object.keys(groupEntries).reduce((acc, key) => { const entries = groupEntries[key]; - const multiContent = - entries[0].multiContent || (collection && collection.get('multi_content')); - return [...acc, this.combineEntries(entries, multiContent)]; + return [...acc, this.combineEntries(entries)]; }, []); } - combineEntries(entries: entryMap[], multiContent: string) { + combineEntries(entries: entryMap[]) { + const { i18nStructure, contentKey, slugWithLocale, ...entry } = entries[0]; const data = {}; let splitChar; let path; - if (multiContent === SAME_FOLDER) { - splitChar = '.'; - } else if (multiContent === DIFF_FOLDER) { - splitChar = '/'; - } entries.forEach(e => { - const entryPath = e.path.split(splitChar); - const locale = entryPath.splice(-2, 1)[0]; - !path && (path = entryPath.join(splitChar)); - data[locale] = e.data; + if (i18nStructure === LOCALE_FILE_EXTENSIONS) { + const entryPath = e.path.split('.'); + const locale = entryPath.splice(-2, 1)[0]; + !path && (path = entryPath.join('.')); + data[locale] = e.data; + } else if (i18nStructure === LOCALE_FOLDERS) { + const locale = e.slugWithLocale.slice(0, 2); + !path && (path = e.path.replace(`${locale}/`, '')); + data[locale] = e.data; + } }); - return { ...entries[0], path, raw: '', data }; + return { ...entry, path, raw: '', data }; } /** @@ -1063,7 +1067,7 @@ export class Backend { const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; const MultiContentDiffFiles = - DIFF_FILE_TYPES.includes(collection.get('multi_content')) && config.get('locales'); + DIFF_FILE_TYPES.includes(collection.get('i18n_structure')) && collection.get('multi_content'); const useWorkflow = selectUseWorkflow(config); @@ -1119,12 +1123,12 @@ export class Backend { let entriesObj = [entryObj]; if (MultiContentDiffFiles) { - const multiContent = collection.get('multi_content'); + const i18nStructure = collection.get('i18n_structure'); const extension = selectFolderEntryExtension(collection); const data = entryDraft.getIn(['entry', 'data']).toJS(); const locales = Object.keys(data); entriesObj = []; - if (multiContent === SAME_FOLDER) { + if (i18nStructure === LOCALE_FILE_EXTENSIONS) { locales.forEach(l => { entriesObj.push({ path: entryObj.path.replace(extension, `${l}.${extension}`), @@ -1135,7 +1139,7 @@ export class Backend { ), }); }); - } else if (multiContent === DIFF_FOLDER) { + } else if (i18nStructure === LOCALE_FOLDERS) { locales.forEach(l => { entriesObj.push({ path: entryObj.path.replace(`${entryObj.slug}`, `${l}/${entryObj.slug}`), @@ -1240,7 +1244,7 @@ export class Backend { const path = selectEntryPath(collection, slug) as string; const extension = selectFolderEntryExtension(collection) as string; const MultiContentDiffFiles = - DIFF_FILE_TYPES.includes(collection.get('multi_content')) && config.get('locales'); + DIFF_FILE_TYPES.includes(collection.get('i18n_structure')) && collection.get('multi_content'); const locales = config.get('locales'); if (!selectAllowDeletion(collection)) { @@ -1264,14 +1268,14 @@ export class Backend { const entry = selectEntry(state.entries, collection.get('name'), slug); await this.invokePreUnpublishEvent(entry); if (MultiContentDiffFiles) { - const multiContent = collection.get('multi_content'); - if (multiContent === SAME_FOLDER) { + const i18nStructure = collection.get('i18n_structure'); + if (i18nStructure === LOCALE_FILE_EXTENSIONS) { for (const l of locales) { await this.implementation .deleteFile(path.replace(extension, `${l}.${extension}`), commitMessage) .catch(() => undefined); } - } else if (multiContent === DIFF_FOLDER) { + } else if (i18nStructure === LOCALE_FOLDERS) { for (const l of locales) { await this.implementation .deleteFile(path.replace(`${slug}`, `${l}/${slug}`), commitMessage) diff --git a/packages/netlify-cms-core/src/components/Editor/Editor.js b/packages/netlify-cms-core/src/components/Editor/Editor.js index 89376a1e2241..6dc5e8c121e2 100644 --- a/packages/netlify-cms-core/src/components/Editor/Editor.js +++ b/packages/netlify-cms-core/src/components/Editor/Editor.js @@ -367,7 +367,6 @@ export class Editor extends React.Component { loadDeployPreview, draftKey, slug, - locales, t, editorBackLink, } = this.props; @@ -417,7 +416,6 @@ export class Editor extends React.Component { currentStatus={currentStatus} onLogoutClick={logoutUser} deployPreview={deployPreview} - locales={locales} loadDeployPreview={opts => loadDeployPreview(collection, slug, entry, isPublished, opts)} editorBackLink={editorBackLink} /> @@ -446,7 +444,6 @@ function mapStateToProps(state, ownProps) { const deployPreview = selectDeployPreview(state, collectionName, slug); const localBackup = entryDraft.get('localBackup'); const draftKey = entryDraft.get('key'); - const locales = config.get('locales'); let editorBackLink = `/collections/${collectionName}`; if (collection.has('nested') && slug) { const pathParts = slug.split('/'); @@ -477,7 +474,6 @@ function mapStateToProps(state, ownProps) { publishedEntry, unPublishedEntry, editorBackLink, - locales, }; } diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js index aa343f407688..558b7d7651c5 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js @@ -215,6 +215,7 @@ class EditorControl extends React.Component { const childErrors = this.isAncestorOfFieldError(); const hasErrors = !!errors || childErrors; const multiContentWidgetId = field.get('multiContentId') === Symbol.for('multiContentId'); + const locales = this.props.collection.get('locales'); const label = ( <> {locales && multiContentWidgetId ? ( diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js index 43e38d5585ab..a746587f27d1 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js @@ -18,6 +18,18 @@ export default class ControlPane extends React.Component { componentValidate = {}; + componentDidUpdate(prevProps) { + if ( + this.props.collection.get('multi_content') && + !prevProps.fieldsErrors.equals(this.props.fieldsErrors) && + this.props.defaultEditor + ) { + // show default locale fields on field error + const defaultLocale = this.props.collection.get('locales').first(); + this.handleLocaleChange(defaultLocale); + } + } + controlRef(field, wrappedControl) { if (!wrappedControl) return; const name = field.get('name'); @@ -26,10 +38,11 @@ export default class ControlPane extends React.Component { wrappedControl.innerWrappedControl?.validate || wrappedControl.validate; } - getFields = () => { + getFields = (defaultLocale = '') => { let fields = this.props.fields; - if (this.props.collection.get('multi_content') && this.props.locales) { - fields = fields.filter(f => f.get('name') === this.state.selectedLocale); + const selectedLocale = defaultLocale || this.state.selectedLocale; + if (this.props.collection.get('multi_content')) { + fields = fields.filter(f => f.get('name') === selectedLocale); } return fields; }; @@ -39,22 +52,16 @@ export default class ControlPane extends React.Component { }; validate = () => { - this.getFields().forEach(field => { + const collection = this.props.collection; + const defaultLocale = collection.get('multi_content') && collection.get('locales').first(); + this.getFields(defaultLocale).forEach(field => { if (field.get('widget') === 'hidden') return; this.componentValidate[field.get('name')](); }); }; render() { - const { - collection, - entry, - fieldsMetaData, - fieldsErrors, - onChange, - onValidate, - locales, - } = this.props; + const { collection, entry, fieldsMetaData, fieldsErrors, onChange, onValidate } = this.props; const fields = this.getFields(); if (!collection || !fields) { @@ -87,7 +94,6 @@ export default class ControlPane extends React.Component { collection={collection} selectedLocale={this.state.selectedLocale} onLocaleChange={this.handleLocaleChange} - locales={locales} /> ); })} diff --git a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js index 117f71e66c53..b318c31ccea7 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js @@ -100,7 +100,7 @@ const Editor = styled.div` const PreviewPaneContainer = styled.div` height: 100%; - overflow-y: auto; + overflow-y: ${props => (props.overFlow ? 'auto' : 'hidden')}; pointer-events: ${props => (props.blockEntry ? 'none' : 'auto')}; `; @@ -157,7 +157,7 @@ class EditorInterface extends Component { }; handleRefValidation = () => { - [this.controlPaneRef, this.controlPaneRef2].filter(Boolean).forEach(c => c.validate()); + this.controlPaneRef.validate(); }; render() { @@ -190,12 +190,12 @@ class EditorInterface extends Component { deployPreview, draftKey, editorBackLink, - locales, } = this.props; const { previewVisible, scrollSyncEnabled, showEventBlocker } = this.state; const collectionPreviewEnabled = collection.getIn(['editor', 'preview'], true); - const multiContent = collection.get('multi_content') && locales; + const multiContent = collection.get('multi_content'); + const locales = this.props.collection.get('locales'); const editorProps = { collection, entry, @@ -204,26 +204,22 @@ class EditorInterface extends Component { fieldsErrors, onChange, onValidate, - locales, }; const editor = ( - + (this.controlPaneRef = c)} + defaultEditor locale={locales && locales.first()} /> ); const editor2 = ( - - (this.controlPaneRef2 = c)} - locale={locales && locales.last()} - /> + + ); diff --git a/packages/netlify-cms-core/src/constants/configSchema.js b/packages/netlify-cms-core/src/constants/configSchema.js index a6a7d7593a5d..42ee023ce686 100644 --- a/packages/netlify-cms-core/src/constants/configSchema.js +++ b/packages/netlify-cms-core/src/constants/configSchema.js @@ -1,16 +1,14 @@ import AJV from 'ajv'; import { select, uniqueItemProperties, instanceof as instanceOf } from 'ajv-keywords/keywords'; import ajvErrors from 'ajv-errors'; -import locale from 'locale-codes'; -import { uniq } from 'lodash'; import { formatExtensions, frontmatterFormats, extensionFormatters } from 'Formats/formats'; import { getWidgets } from 'Lib/registry'; -import { SINGLE_FILE, SAME_FOLDER, DIFF_FOLDER } from 'Constants/multiContentTypes'; - -/** - * valid locales. - */ -const locales = uniq(locale.all.map(l => l['iso639-1']).filter(Boolean)); +import { + locales, + SINGLE_FILE, + LOCALE_FILE_EXTENSIONS, + LOCALE_FOLDERS, +} from 'Constants/multiContentTypes'; /** * Config for fields in both file and folder collections. @@ -138,6 +136,7 @@ const getConfigSchema = () => ({ type: 'array', minItems: 2, items: { type: 'string', enum: locales }, + uniqueItems: true, }, collections: { type: 'array', @@ -193,7 +192,6 @@ const getConfigSchema = () => ({ type: 'string', }, }, -<<<<<<< HEAD fields: fieldsConfig(), sortableFields: { type: 'array', @@ -226,20 +224,29 @@ const getConfigSchema = () => ({ additionalProperties: false, minProperties: 1, }, - multi_content: { type: 'string', enum: [SINGLE_FILE, SAME_FOLDER, DIFF_FOLDER] }, + i18n_structure: { + type: 'string', + enum: [SINGLE_FILE, LOCALE_FILE_EXTENSIONS, LOCALE_FOLDERS], + }, + default_locale: { type: 'string', enum: locales }, }, required: ['name', 'label'], oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }], - if: { required: ['extension'] }, - then: { - // Cannot infer format from extension. - if: { - properties: { - extension: { enum: Object.keys(extensionFormatters) }, + allOf: [ + { + if: { required: ['extension'] }, + then: { + // Cannot infer format from extension. + if: { + properties: { + extension: { enum: Object.keys(extensionFormatters) }, + }, + }, + else: { required: ['format'] }, }, }, - else: { required: ['format'] }, - }, + { if: { required: ['files'] }, then: { not: { required: ['i18n_structure'] } } }, + ], dependencies: { frontmatter_delimiter: { properties: { diff --git a/packages/netlify-cms-core/src/constants/multiContentTypes.js b/packages/netlify-cms-core/src/constants/multiContentTypes.js index 207863a1f2ee..4d52c49749f7 100644 --- a/packages/netlify-cms-core/src/constants/multiContentTypes.js +++ b/packages/netlify-cms-core/src/constants/multiContentTypes.js @@ -1,4 +1,129 @@ export const SINGLE_FILE = 'single_file'; -export const SAME_FOLDER = 'same_folder'; -export const DIFF_FOLDER = 'diff_folder'; -export const DIFF_FILE_TYPES = [SAME_FOLDER, DIFF_FOLDER]; +export const LOCALE_FILE_EXTENSIONS = 'locale_file_extensions'; +export const LOCALE_FOLDERS = 'locale_folders'; +export const DIFF_FILE_TYPES = [LOCALE_FILE_EXTENSIONS, LOCALE_FOLDERS]; +export const NON_TRANSLATABLE_FIELDS = ['date', 'datetime', 'map', 'code', 'number']; + +export const locales = [ + 'aa', + 'af', + 'ak', + 'sq', + 'am', + 'ar', + 'hy', + 'as', + 'ba', + 'eu', + 'be', + 'br', + 'bg', + 'my', + 'ca', + 'ce', + 'cu', + 'kw', + 'co', + 'hr', + 'cs', + 'da', + 'dv', + 'nl', + 'dz', + 'en', + 'eo', + 'et', + 'ee', + 'fo', + 'fi', + 'fr', + 'ff', + 'gl', + 'lg', + 'ka', + 'de', + 'kl', + 'gn', + 'gu', + 'he', + 'hi', + 'hu', + 'is', + 'ig', + 'id', + 'ga', + 'it', + 'ja', + 'jv', + 'kn', + 'ks', + 'kk', + 'ki', + 'rw', + 'ko', + 'ky', + 'ku', + 'lo', + 'lv', + 'ln', + 'lt', + 'lu', + 'lb', + 'mk', + 'mg', + 'ms', + 'ml', + 'mt', + 'gv', + 'mi', + 'mr', + 'ne', + 'nd', + 'nb', + 'om', + 'os', + 'ps', + 'fa', + 'pl', + 'pt', + 'pa', + 'qu', + 'ro', + 'rm', + 'rn', + 'ru', + 'sg', + 'sa', + 'gd', + 'sn', + 'sd', + 'si', + 'sk', + 'sl', + 'so', + 'nr', + 'es', + 'ss', + 'sv', + 'ta', + 'tt', + 'te', + 'th', + 'bo', + 'ti', + 'ts', + 'tr', + 'tk', + 'uk', + 'ur', + 'ug', + 'uz', + 've', + 'vi', + 'vo', + 'cy', + 'wo', + 'xh', + 'yo', + 'zu', +]; diff --git a/packages/netlify-cms-core/src/valueObjects/Entry.ts b/packages/netlify-cms-core/src/valueObjects/Entry.ts index e08eb66b5ec5..056baa03c3ae 100644 --- a/packages/netlify-cms-core/src/valueObjects/Entry.ts +++ b/packages/netlify-cms-core/src/valueObjects/Entry.ts @@ -30,8 +30,8 @@ export interface EntryValue { updatedOn: string; status?: string; meta: { path?: string }; - multiContent?: string; - multiContentKey?: string; + i18nStructure?: string; + contentKey?: string; } export function createEntry(collection: string, slug = '', path = '', options: Options = {}) { @@ -49,11 +49,11 @@ export function createEntry(collection: string, slug = '', path = '', options: O updatedOn: options.updatedOn || '', status: options.status || '', meta: options.meta || {}, - ...(options.multiContentKey && { - multiContentKey: options.multiContentKey, + ...(options.contentKey && { + contentKey: options.contentKey, }), - ...(options.multiContent && { - multiContent: options.multiContent, + ...(options.i18nStructure && { + i18nStructure: options.i18nStructure, }), }; diff --git a/packages/netlify-cms-widget-markdown/src/MarkdownControl/RawEditor.js b/packages/netlify-cms-widget-markdown/src/MarkdownControl/RawEditor.js index 12a8ded731dc..3c51b8312bcf 100644 --- a/packages/netlify-cms-widget-markdown/src/MarkdownControl/RawEditor.js +++ b/packages/netlify-cms-widget-markdown/src/MarkdownControl/RawEditor.js @@ -38,7 +38,16 @@ export default class RawEditor extends React.Component { } shouldComponentUpdate(nextProps, nextState) { - return !this.state.value.equals(nextState.value); + return ( + !this.state.value.equals(nextState.value) || + nextProps.value !== Plain.serialize(nextState.value) + ); + } + + componentDidUpdate(prevProps) { + if (prevProps.value !== this.props.value) { + this.setState({ value: Plain.deserialize(this.props.value) }); + } } componentDidMount() { diff --git a/packages/netlify-cms-widget-markdown/src/MarkdownControl/VisualEditor.js b/packages/netlify-cms-widget-markdown/src/MarkdownControl/VisualEditor.js index bfdb56633a6b..07188fc0bfab 100644 --- a/packages/netlify-cms-widget-markdown/src/MarkdownControl/VisualEditor.js +++ b/packages/netlify-cms-widget-markdown/src/MarkdownControl/VisualEditor.js @@ -120,7 +120,9 @@ export default class Editor extends React.Component { }; shouldComponentUpdate(nextProps, nextState) { - return !this.state.value.equals(nextState.value); + const raw = nextState.value.document.toJS(); + const markdown = slateToMarkdown(raw, { voidCodeBlock: this.codeBlockComponent }); + return !this.state.value.equals(nextState.value) || nextProps.value !== markdown; } componentDidMount() { @@ -130,6 +132,14 @@ export default class Editor extends React.Component { } } + componentDidUpdate(prevProps) { + if (prevProps.value !== this.props.value) { + this.setState({ + value: createSlateValue(this.props.value, { voidCodeBlock: !!this.codeBlockComponent }), + }); + } + } + handleMarkClick = type => { this.editor.toggleMark(type).focus(); }; diff --git a/yarn.lock b/yarn.lock index 7661990eae88..7bac8b2cc652 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10459,11 +10459,6 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= -iso639-codes@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/iso639-codes/-/iso639-codes-1.0.1.tgz#674a45cdabbfdf3719b8b971b93ca1eedbbb4a1d" - integrity sha512-jdTSv8yn6D7GODDrRtuWG7y3du3aoa+ki5H8h/Y48/NleNAd7Fw/M2niTTLXGH4QnqhJ98hg1JMQtP9csQ31Lg== - isobject@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" @@ -18414,11 +18409,6 @@ widest-line@^3.1.0: dependencies: string-width "^4.0.0" -windows-locale@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/windows-locale/-/windows-locale-1.0.1.tgz#b965309efddc48bf44912c8e596dd3796387e568" - integrity sha512-X8B22Cg9njwV4h3C5j28xmZ2eWaO69j63WhReeglB69LOT3LoqSO4Vb6TTVSfFikh4KQ9qBOJb6+WvR4tVLTfQ== - windows-release@^3.1.0: version "3.3.1" resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.3.1.tgz#cb4e80385f8550f709727287bf71035e209c4ace" From e89f692d2ac88a8305fb158d4f14d83e838dc5d5 Mon Sep 17 00:00:00 2001 From: barthc Date: Tue, 10 Mar 2020 11:35:17 +0100 Subject: [PATCH 12/23] fix: rename readUnpublishedBranchFile --- .../netlify-cms-core/src/actions/editorialWorkflow.ts | 1 - packages/netlify-cms-core/src/actions/entries.ts | 1 - packages/netlify-cms-core/src/backend.ts | 9 +++++---- packages/netlify-cms-lib-util/src/implementation.ts | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/netlify-cms-core/src/actions/editorialWorkflow.ts b/packages/netlify-cms-core/src/actions/editorialWorkflow.ts index b8d09d36c5f7..cc30bb223fbb 100644 --- a/packages/netlify-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/netlify-cms-core/src/actions/editorialWorkflow.ts @@ -267,7 +267,6 @@ export function loadUnpublishedEntry(collection: Collection, slug: string) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const backend = currentBackend(state.config); - const locales = state.config.get('locales'); const multiContent = collection.get('multi_content'); const i18nStructure = collection.get('i18n_structure'); const entriesLoaded = get(state.editorialWorkflow.toJS(), 'pages.ids', false); diff --git a/packages/netlify-cms-core/src/actions/entries.ts b/packages/netlify-cms-core/src/actions/entries.ts index 03efc4b189df..472135a24a70 100644 --- a/packages/netlify-cms-core/src/actions/entries.ts +++ b/packages/netlify-cms-core/src/actions/entries.ts @@ -526,7 +526,6 @@ export function loadEntries(collection: Collection, page = 0) { } const backend = currentBackend(state.config); - const locales = state.config.get('locales'); const multiContent = collection.get('multi_content'); const i18nStructure = collection.get('i18n_structure'); const integration = selectIntegration(state, collection.get('name'), 'listEntries'); diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index 0cb1c35b29b4..1cb1dbe0d8df 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -517,8 +517,9 @@ export class Backend { return entries; } - async listAllMultipleEntires(collection: Collection, page: number, locales: string[]) { + async listAllMultipleEntires(collection: Collection, page: number) { const i18nStructure = collection.get('i18n_structure'); + const locales = collection.get('locales'); const depth = i18nStructure === LOCALE_FOLDERS ? 2 : ''; const entries = await this.listAllEntries(collection, depth); let multiEntries; @@ -550,7 +551,7 @@ export class Backend { }; }); } - return { entries: this.combineMultipleContentEntries(multiEntries, collection) }; + return { entries: this.combineMultipleContentEntries(multiEntries) }; } async search(collections: Collection[], searchTerm: string) { @@ -750,7 +751,7 @@ export class Backend { return localForage.removeItem(getEntryBackupKey()); } -async getEntry(state: State, collection: Collection, slug: string) { + async getEntry(state: State, collection: Collection, slug: string) { const path = selectEntryPath(collection, slug) as string; const label = selectFileEntryLabel(collection, slug); const extension = selectFolderEntryExtension(collection); @@ -1245,7 +1246,7 @@ async getEntry(state: State, collection: Collection, slug: string) { const extension = selectFolderEntryExtension(collection) as string; const MultiContentDiffFiles = DIFF_FILE_TYPES.includes(collection.get('i18n_structure')) && collection.get('multi_content'); - const locales = config.get('locales'); + const locales = collection.get('locales'); if (!selectAllowDeletion(collection)) { throw new Error('Not allowed to delete entries in this collection'); diff --git a/packages/netlify-cms-lib-util/src/implementation.ts b/packages/netlify-cms-lib-util/src/implementation.ts index f267179585dc..e6db07bf2e4e 100644 --- a/packages/netlify-cms-lib-util/src/implementation.ts +++ b/packages/netlify-cms-lib-util/src/implementation.ts @@ -211,7 +211,7 @@ const fetchFiles = async ( ); }); return Promise.all(promises).then(loadedEntries => - loadedEntries.flat().filter(loadedEntry => !(loadedEntry as { error: boolean }).error), + loadedEntries.filter(loadedEntry => !(loadedEntry as { error: boolean }).error), ) as Promise; }; From 4fa8dfaf9ba60efc312d38c541ed089542ecde50 Mon Sep 17 00:00:00 2001 From: barthc Date: Mon, 16 Mar 2020 14:41:34 +0100 Subject: [PATCH 13/23] fix: add more unit tests --- .../src/__tests__/backend.spec.js | 62 ++++++++++++ packages/netlify-cms-core/src/backend.ts | 75 +++++++------- .../src/constants/multiContentTypes.js | 98 +++++++++---------- 3 files changed, 151 insertions(+), 84 deletions(-) diff --git a/packages/netlify-cms-core/src/__tests__/backend.spec.js b/packages/netlify-cms-core/src/__tests__/backend.spec.js index 100194da8b1f..c995119bb551 100644 --- a/packages/netlify-cms-core/src/__tests__/backend.spec.js +++ b/packages/netlify-cms-core/src/__tests__/backend.spec.js @@ -1145,4 +1145,66 @@ describe('Backend', () => { ]); }); }); + + describe('getMultipleEntries', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const implementation = { + init: jest.fn(() => implementation), + }; + const config = Map({}); + const backend = new Backend(implementation, { config, backendName: 'github' }); + const entryDraft = fromJS({ + entry: { + data: { + en: { title: 'post', content: 'Content en' }, + fr: { title: 'publier', content: 'Content fr' }, + }, + }, + }); + const entryObj = { path: 'posts/post.md', slug: 'post' }; + + it('should split multiple content into different locale file entries', async () => { + const collection = fromJS({ + i18n_structure: 'locale_file_extensions', + fields: [{ name: 'title' }, { name: 'content' }], + extension: 'md', + }); + + expect(backend.getMultipleEntries(collection, entryDraft, entryObj)).toEqual([ + { + slug: 'post', + path: 'posts/post.en.md', + raw: '---\ntitle: post\ncontent: Content en\n---\n', + }, + { + slug: 'post', + path: 'posts/post.fr.md', + raw: '---\ntitle: publier\ncontent: Content fr\n---\n', + }, + ]); + }); + + it('should split multiple content into different locale folder entries', async () => { + const collection = fromJS({ + i18n_structure: 'locale_folders', + fields: [{ name: 'title' }, { name: 'content' }], + extension: 'md', + }); + + expect(backend.getMultipleEntries(collection, entryDraft, entryObj)).toEqual([ + { + slug: 'post', + path: 'posts/en/post.md', + raw: '---\ntitle: post\ncontent: Content en\n---\n', + }, + { + slug: 'post', + path: 'posts/fr/post.md', + raw: '---\ntitle: publier\ncontent: Content fr\n---\n', + }, + ]); + }); + }); }); diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index 1cb1dbe0d8df..2f9a3e9f54eb 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -48,6 +48,7 @@ import { FilterRule, Collections, EntryDraft, + Entry, CollectionFile, State, EntryField, @@ -527,12 +528,11 @@ export class Backend { multiEntries = entries .filter(entry => locales.some(l => entry.slug.endsWith(`.${l}`))) .map(entry => { - const path = entry.path.split('.'); - const locale = path.splice(-2, 2)[0]; + const locale = entry.slug.slice(-2); return { ...entry, slug: entry.slug.replace(`.${locale}`, ''), - contentKey: path.join('.'), + contentKey: entry.path.replace(`.${locale}`, ''), i18nStructure, slugWithLocale: entry.slug, }; @@ -967,9 +967,8 @@ export class Backend { let path; entries.forEach(e => { if (i18nStructure === LOCALE_FILE_EXTENSIONS) { - const entryPath = e.path.split('.'); - const locale = entryPath.splice(-2, 1)[0]; - !path && (path = entryPath.join('.')); + const locale = e.slugWithLocale.slice(-2); + !path && (path = e.path.replace(`.${locale}`, '')); data[locale] = e.data; } else if (i18nStructure === LOCALE_FOLDERS) { const locale = e.slugWithLocale.slice(0, 2); @@ -1124,34 +1123,7 @@ export class Backend { let entriesObj = [entryObj]; if (MultiContentDiffFiles) { - const i18nStructure = collection.get('i18n_structure'); - const extension = selectFolderEntryExtension(collection); - const data = entryDraft.getIn(['entry', 'data']).toJS(); - const locales = Object.keys(data); - entriesObj = []; - if (i18nStructure === LOCALE_FILE_EXTENSIONS) { - locales.forEach(l => { - entriesObj.push({ - path: entryObj.path.replace(extension, `${l}.${extension}`), - slug: entryObj.slug, - raw: this.entryToRaw( - collection, - entryDraft.get('entry').set('data', entryDraft.getIn(['entry', 'data', l])), - ), - }); - }); - } else if (i18nStructure === LOCALE_FOLDERS) { - locales.forEach(l => { - entriesObj.push({ - path: entryObj.path.replace(`${entryObj.slug}`, `${l}/${entryObj.slug}`), - slug: entryObj.slug, - raw: this.entryToRaw( - collection, - entryDraft.get('entry').set('data', entryDraft.getIn(['entry', 'data', l])), - ), - }); - }); - } + entriesObj = this.getMultipleEntries(collection, entryDraft, entryObj); } const user = (await this.currentUser()) as User; @@ -1194,6 +1166,39 @@ export class Backend { return entryObj.slug; } + getMultipleEntries(collection: Collection, entryDraft: EntryDraft, entryObj: Entry) { + const i18nStructure = collection.get('i18n_structure'); + const extension = selectFolderEntryExtension(collection); + const data = entryDraft.getIn(['entry', 'data']).toJS(); + const locales = Object.keys(data); + const entriesObj = []; + if (i18nStructure === LOCALE_FILE_EXTENSIONS) { + locales.forEach(l => { + entriesObj.push({ + path: entryObj.path.replace(extension, `${l}.${extension}`), + slug: entryObj.slug, + raw: this.entryToRaw( + collection, + entryDraft.get('entry').set('data', entryDraft.getIn(['entry', 'data', l])), + ), + }); + }); + } else if (i18nStructure === LOCALE_FOLDERS) { + locales.forEach(l => { + entriesObj.push({ + path: entryObj.path.replace(`/${entryObj.slug}`, `/${l}/${entryObj.slug}`), + slug: entryObj.slug, + raw: this.entryToRaw( + collection, + entryDraft.get('entry').set('data', entryDraft.getIn(['entry', 'data', l])), + ), + }); + }); + } + + return entriesObj; + } + async invokeEventWithEntry(event: string, entry: EntryMap) { const { login, name } = (await this.currentUser()) as User; return await invokeEvent({ name: event, data: { entry, author: { login, name } } }); @@ -1279,7 +1284,7 @@ export class Backend { } else if (i18nStructure === LOCALE_FOLDERS) { for (const l of locales) { await this.implementation - .deleteFile(path.replace(`${slug}`, `${l}/${slug}`), commitMessage) + .deleteFile(path.replace(`/${slug}`, `/${l}/${slug}`), commitMessage) .catch(() => undefined); } } diff --git a/packages/netlify-cms-core/src/constants/multiContentTypes.js b/packages/netlify-cms-core/src/constants/multiContentTypes.js index 4d52c49749f7..4759f30bf50f 100644 --- a/packages/netlify-cms-core/src/constants/multiContentTypes.js +++ b/packages/netlify-cms-core/src/constants/multiContentTypes.js @@ -8,120 +8,120 @@ export const locales = [ 'aa', 'af', 'ak', - 'sq', 'am', 'ar', - 'hy', 'as', 'ba', - 'eu', 'be', - 'br', 'bg', - 'my', + 'bo', + 'br', 'ca', 'ce', - 'cu', - 'kw', 'co', - 'hr', 'cs', + 'cu', + 'cy', 'da', + 'de', 'dv', - 'nl', 'dz', + 'ee', 'en', 'eo', + 'es', 'et', - 'ee', - 'fo', + 'eu', + 'fa', + 'ff', 'fi', + 'fo', 'fr', - 'ff', + 'ga', + 'gd', 'gl', - 'lg', - 'ka', - 'de', - 'kl', 'gn', 'gu', + 'gv', 'he', 'hi', + 'hr', 'hu', - 'is', - 'ig', + 'hy', 'id', - 'ga', + 'ig', + 'is', 'it', 'ja', 'jv', - 'kn', - 'ks', - 'kk', + 'ka', 'ki', - 'rw', + 'kk', + 'kl', + 'kn', 'ko', - 'ky', + 'ks', 'ku', - 'lo', - 'lv', + 'kw', + 'ky', + 'lb', + 'lg', 'ln', + 'lo', 'lt', 'lu', - 'lb', - 'mk', + 'lv', 'mg', - 'ms', - 'ml', - 'mt', - 'gv', 'mi', + 'mk', + 'ml', 'mr', - 'ne', - 'nd', + 'ms', + 'mt', + 'my', 'nb', + 'nd', + 'ne', + 'nl', + 'nr', 'om', 'os', - 'ps', - 'fa', + 'pa', 'pl', + 'ps', 'pt', - 'pa', 'qu', - 'ro', 'rm', 'rn', + 'ro', 'ru', - 'sg', + 'rw', 'sa', - 'gd', - 'sn', 'sd', + 'sg', 'si', 'sk', 'sl', + 'sn', 'so', - 'nr', - 'es', + 'sq', 'ss', 'sv', 'ta', - 'tt', 'te', 'th', - 'bo', 'ti', - 'ts', - 'tr', 'tk', + 'tr', + 'ts', + 'tt', + 'ug', 'uk', 'ur', - 'ug', 'uz', 've', 'vi', 'vo', - 'cy', 'wo', 'xh', 'yo', From 2941a4fbdbc4e96fb18fd277907ddca403a197c8 Mon Sep 17 00:00:00 2001 From: barthc Date: Wed, 24 Jun 2020 15:21:57 +0100 Subject: [PATCH 14/23] chore: rebase clean up --- dev-test/config.yml | 24 ++++- .../src/__tests__/API.spec.js | 2 +- .../src/implementation.ts | 7 +- .../src/__tests__/backend.spec.js | 64 +---------- .../src/actions/__tests__/config.spec.js | 24 +++-- .../netlify-cms-core/src/actions/config.js | 19 ++-- .../src/actions/editorialWorkflow.ts | 5 +- .../netlify-cms-core/src/actions/entries.ts | 25 +---- packages/netlify-cms-core/src/backend.ts | 100 ++++++++++++------ .../Editor/EditorControlPane/EditorControl.js | 42 ++++---- .../src/constants/multiContentTypes.js | 1 - .../src/valueObjects/Entry.ts | 4 + 12 files changed, 155 insertions(+), 162 deletions(-) diff --git a/dev-test/config.yml b/dev-test/config.yml index 2dd82bcac504..0361bf29e9bd 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -1,11 +1,16 @@ backend: - name: test-repo + name: github + repo: bastiaan02/jekyll-base site_url: https://example.com publish_mode: editorial_workflow media_folder: assets/uploads +locales: + - en + - fr + collections: # A list of collections the CMS should be able to edit - name: 'posts' # Used in routes, ie.: /admin/collections/:slug/edit label: 'Posts' # Used in the UI @@ -45,7 +50,22 @@ collections: # A list of collections the CMS should be able to edit tagname: '' - { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' } - + - name: 'blog' # Used in routes, ie.: /admin/collections/:slug/edit + label: 'BLOG' # Used in the UI + folder: '_blogs' + i18n_structure: locale_file_extensions + create: true # Allow users to create new documents in this collection + fields: # The fields each document in this collection have + - { label: 'Title', name: 'title', widget: 'string', translatable: true, tagname: 'h1' } + - { label: 'Answer', name: 'body', widget: 'markdown', translatable: true } + - name: 'product' # Used in routes, ie.: /admin/collections/:slug/edit + label: 'PRODUCT' # Used in the UI + folder: '_products' + i18n_structure: locale_folders + create: true # Allow users to create new documents in this collection + fields: # The fields each document in this collection have + - { label: 'Title', name: 'title', widget: 'string', translatable: true, tagname: 'h1' } + - { label: 'Description', name: 'body', widget: 'markdown', translatable: true } - name: 'faq' # Used in routes, ie.: /admin/collections/:slug/edit label: 'FAQ' # Used in the UI folder: '_faqs' diff --git a/packages/netlify-cms-backend-github/src/__tests__/API.spec.js b/packages/netlify-cms-backend-github/src/__tests__/API.spec.js index 8eed28041d35..fa84c1521454 100644 --- a/packages/netlify-cms-backend-github/src/__tests__/API.spec.js +++ b/packages/netlify-cms-backend-github/src/__tests__/API.spec.js @@ -200,7 +200,7 @@ describe('github API', () => { path: 'content/posts/new-post.md', raw: 'content', }; - await api.persistFiles(entry, [], { commitMessage: 'commitMessage' }); + await api.persistFiles([entry], [], { commitMessage: 'commitMessage' }); expect(api.request).toHaveBeenCalledTimes(5); diff --git a/packages/netlify-cms-backend-test/src/implementation.ts b/packages/netlify-cms-backend-test/src/implementation.ts index c3127fb7ae27..b6b14b687438 100644 --- a/packages/netlify-cms-backend-test/src/implementation.ts +++ b/packages/netlify-cms-backend-test/src/implementation.ts @@ -295,11 +295,8 @@ export default class TestBackend implements Implementation { }; } - async persistEntry( - { path, raw, slug, newPath }: Entry, - assetProxies: AssetProxy[], - options: PersistOptions, - ) { + async persistEntry(entries: Entry, assetProxies: AssetProxy[], options: PersistOptions) { + const { path, raw, slug, newPath } = entries[0]; if (options.useWorkflow) { const key = `${options.collectionName}/${slug}`; const currentEntry = window.repoFilesUnpublished[key]; diff --git a/packages/netlify-cms-core/src/__tests__/backend.spec.js b/packages/netlify-cms-core/src/__tests__/backend.spec.js index c995119bb551..dd9d519e3c41 100644 --- a/packages/netlify-cms-core/src/__tests__/backend.spec.js +++ b/packages/netlify-cms-core/src/__tests__/backend.spec.js @@ -941,7 +941,7 @@ describe('Backend', () => { }); }); - describe('combineMultipleContentEntries', () => { + describe('combineMultipleContentEntries', () => { const implementation = { init: jest.fn(() => implementation), }; @@ -1145,66 +1145,4 @@ describe('Backend', () => { ]); }); }); - - describe('getMultipleEntries', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - const implementation = { - init: jest.fn(() => implementation), - }; - const config = Map({}); - const backend = new Backend(implementation, { config, backendName: 'github' }); - const entryDraft = fromJS({ - entry: { - data: { - en: { title: 'post', content: 'Content en' }, - fr: { title: 'publier', content: 'Content fr' }, - }, - }, - }); - const entryObj = { path: 'posts/post.md', slug: 'post' }; - - it('should split multiple content into different locale file entries', async () => { - const collection = fromJS({ - i18n_structure: 'locale_file_extensions', - fields: [{ name: 'title' }, { name: 'content' }], - extension: 'md', - }); - - expect(backend.getMultipleEntries(collection, entryDraft, entryObj)).toEqual([ - { - slug: 'post', - path: 'posts/post.en.md', - raw: '---\ntitle: post\ncontent: Content en\n---\n', - }, - { - slug: 'post', - path: 'posts/post.fr.md', - raw: '---\ntitle: publier\ncontent: Content fr\n---\n', - }, - ]); - }); - - it('should split multiple content into different locale folder entries', async () => { - const collection = fromJS({ - i18n_structure: 'locale_folders', - fields: [{ name: 'title' }, { name: 'content' }], - extension: 'md', - }); - - expect(backend.getMultipleEntries(collection, entryDraft, entryObj)).toEqual([ - { - slug: 'post', - path: 'posts/en/post.md', - raw: '---\ntitle: post\ncontent: Content en\n---\n', - }, - { - slug: 'post', - path: 'posts/fr/post.md', - raw: '---\ntitle: publier\ncontent: Content fr\n---\n', - }, - ]); - }); - }); }); 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 f7b5595c1129..9af4573f0c67 100644 --- a/packages/netlify-cms-core/src/actions/__tests__/config.spec.js +++ b/packages/netlify-cms-core/src/actions/__tests__/config.spec.js @@ -1,10 +1,12 @@ import { fromJS } from 'immutable'; -<<<<<<< HEAD import { stripIndent } from 'common-tags'; -import { parseConfig, applyDefaults, detectProxyServer, handleLocalBackend } from '../config'; -======= -import { applyDefaults, detectProxyServer, handleLocalBackend, addLocaleFields } from '../config'; ->>>>>>> 6f4b539c... fix: add unit tests +import { + parseConfig, + applyDefaults, + detectProxyServer, + handleLocalBackend, + addLocaleFields, +} from '../config'; jest.spyOn(console, 'log').mockImplementation(() => {}); jest.mock('coreSrc/backend', () => { @@ -582,9 +584,9 @@ describe('config', () => { describe('addLocaleFields', () => { it('should add locale fields', () => { const fields = fromJS([ - { name: 'title', widget: 'string' }, + { name: 'title', widget: 'string', translatable: true }, { name: 'date', widget: 'date' }, - { name: 'content', widget: 'markdown' }, + { name: 'content', widget: 'markdown', translatable: true }, ]); const actual = addLocaleFields(fields, ['en', 'fr']); @@ -596,9 +598,9 @@ describe('config', () => { widget: 'object', multiContentId: Symbol.for('multiContentId'), fields: [ - { name: 'title', widget: 'string' }, + { name: 'title', widget: 'string', translatable: true }, { name: 'date', widget: 'date' }, - { name: 'content', widget: 'markdown' }, + { name: 'content', widget: 'markdown', translatable: true }, ], }, { @@ -607,8 +609,8 @@ describe('config', () => { widget: 'object', multiContentId: Symbol.for('multiContentId'), fields: [ - { name: 'title', widget: 'string' }, - { name: 'content', widget: 'markdown' }, + { name: 'title', widget: 'string', translatable: true }, + { name: 'content', widget: 'markdown', translatable: true }, ], }, ]), diff --git a/packages/netlify-cms-core/src/actions/config.js b/packages/netlify-cms-core/src/actions/config.js index 0e2cdfd9678a..272d3343dcb2 100644 --- a/packages/netlify-cms-core/src/actions/config.js +++ b/packages/netlify-cms-core/src/actions/config.js @@ -1,11 +1,16 @@ import yaml from 'yaml'; -import { Map, fromJS } from 'immutable'; -import { trimStart, trim, get, isPlainObject } from 'lodash'; +import { Map, List, fromJS } from 'immutable'; +import { trimStart, trim, get, isPlainObject, uniq, isEmpty } from 'lodash'; import { authenticateUser } from 'Actions/auth'; import * as publishModes from 'Constants/publishModes'; import { validateConfig } from 'Constants/configSchema'; -import { selectDefaultSortableFields, traverseFields, selectIdentifier } from '../reducers/collections'; -import { NON_TRANSLATABLE_FIELDS } from 'Constants/multiContentTypes'; +import { + selectDefaultSortableFields, + traverseFields, + selectIdentifier, +} from '../reducers/collections'; +import { resolveBackend } from 'coreSrc/backend'; +import { DIFF_FILE_TYPES } from 'Constants/multiContentTypes'; export const CONFIG_REQUEST = 'CONFIG_REQUEST'; export const CONFIG_SUCCESS = 'CONFIG_SUCCESS'; @@ -124,6 +129,9 @@ export function applyDefaults(config) { if (backend === 'test-repo') { collection = collection.set('i18n_structure', 'single_file'); } + if (DIFF_FILE_TYPES.includes(collection.get('i18n_structure'))) { + collection = collection.set('multi_content_diff_files', true); + } } } @@ -190,7 +198,6 @@ export function addLocaleFields(fields, locales) { function stripNonTranslatableFields(fields) { return fields.reduce((acc, item) => { const subfields = item.get('field') || item.get('fields'); - const widget = item.get('widget'); if (List.isList(subfields)) { return acc.push(item.set('fields', stripNonTranslatableFields(subfields))); @@ -200,7 +207,7 @@ function stripNonTranslatableFields(fields) { return acc.push(item.set('field', stripNonTranslatableFields([subfields]))); } - if (!NON_TRANSLATABLE_FIELDS.includes(widget)) { + if (item.get('translatable')) { return acc.push(item); } diff --git a/packages/netlify-cms-core/src/actions/editorialWorkflow.ts b/packages/netlify-cms-core/src/actions/editorialWorkflow.ts index cc30bb223fbb..7704fbd83ecc 100644 --- a/packages/netlify-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/netlify-cms-core/src/actions/editorialWorkflow.ts @@ -23,7 +23,6 @@ import { createDraftFromEntry, loadEntries, } from './entries'; -import { DIFF_FILE_TYPES } from 'Constants/multiContentTypes'; import { createAssetProxy } from '../valueObjects/AssetProxy'; import { addAssets } from './media'; import { loadMedia } from './mediaLibrary'; @@ -282,7 +281,7 @@ export function loadUnpublishedEntry(collection: Collection, slug: string) { dispatch(unpublishedEntryLoading(collection, slug)); try { - const entry = (await backend.unpublishedEntry(state, collection, slug)) as EntryValue; + let entry = (await backend.unpublishedEntry(state, collection, slug)) as EntryValue; const assetProxies = await Promise.all( entry.mediaFiles .filter(file => file.draft) @@ -296,7 +295,7 @@ export function loadUnpublishedEntry(collection: Collection, slug: string) { ); dispatch(addAssets(assetProxies)); - if (multiContent && DIFF_FILE_TYPES.includes(i18nStructure)) { + if (collection.get('multi_content_diff_files')) { const publishedEntries = get( getState().entries.toJS(), `pages.${collection.get('name')}.ids`, diff --git a/packages/netlify-cms-core/src/actions/entries.ts b/packages/netlify-cms-core/src/actions/entries.ts index 472135a24a70..8515e1854a2a 100644 --- a/packages/netlify-cms-core/src/actions/entries.ts +++ b/packages/netlify-cms-core/src/actions/entries.ts @@ -11,7 +11,6 @@ import { Cursor, ImplementationMediaFile } from 'netlify-cms-lib-util'; import { createEntry, EntryValue } from '../valueObjects/Entry'; import AssetProxy, { createAssetProxy } from '../valueObjects/AssetProxy'; import ValidationErrorTypes from '../constants/validationErrorTypes'; -import { DIFF_FILE_TYPES } from 'Constants/multiContentTypes'; import { addAssets, getAsset } from './media'; import { Collection, @@ -461,7 +460,7 @@ export function loadEntry(collection: Collection, slug: string) { dispatch(entryLoading(collection, slug)); try { - const loadedEntry = await tryLoadEntry(getState(), collection, slug, locales, multiContent); + const loadedEntry = await tryLoadEntry(getState(), collection, slug); dispatch(entryLoaded(collection, loadedEntry)); dispatch(createDraftFromEntry(loadedEntry)); } catch (error) { @@ -481,21 +480,9 @@ export function loadEntry(collection: Collection, slug: string) { }; } -export async function tryLoadEntry( - state: State, - collection: Collection, - slug: string, - locales, - multiContent -) { +export async function tryLoadEntry(state: State, collection: Collection, slug: string) { const backend = currentBackend(state.config); - const loadedEntry = await backend.getEntry( - state, - collection, - slug, - locales, - multiContent - ); + const loadedEntry = await backend.getEntry(state, collection, slug); return loadedEntry; } @@ -526,8 +513,6 @@ export function loadEntries(collection: Collection, page = 0) { } const backend = currentBackend(state.config); - const multiContent = collection.get('multi_content'); - const i18nStructure = collection.get('i18n_structure'); const integration = selectIntegration(state, collection.get('name'), 'listEntries'); const provider = integration ? getIntegrationProvider(state.integrations, backend.getToken, integration) @@ -543,8 +528,8 @@ export function loadEntries(collection: Collection, page = 0) { } = await (collection.has('nested') ? // nested collections require all entries to construct the tree provider.listAllEntries(collection).then((entries: EntryValue[]) => ({ entries })) - : locales && DIFF_FILE_TYPES.includes(multiContent) - ? provider.listAllMultipleEntires(collection, locales) + : collection.get('multi_content_diff_files') + ? provider.listAllMultipleEntires(collection) : provider.listEntries(collection, page)); response = { ...response, diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index 2f9a3e9f54eb..d07303f42f76 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -496,7 +496,7 @@ export class Backend { // for local searches and queries. async listAllEntries(collection: Collection, depth: number) { const selectedDepth = - depth || + depth || collection.get('nested')?.get('depth') || getPathDepth(collection.get('path', '') as string); @@ -551,6 +551,7 @@ export class Backend { }; }); } + return { entries: this.combineMultipleContentEntries(multiEntries) }; } @@ -850,20 +851,53 @@ export class Backend { } else { extension = selectFolderEntryExtension(collection); } + + const mediaFiles: MediaFile[] = []; + if (withMediaFiles) { + const nonDataFiles = entryData.diffs.filter(d => !d.path.endsWith(extension)); + const files = await Promise.all( + nonDataFiles.map(f => + this.implementation!.unpublishedEntryMediaFile( + collection.get('name'), + slug, + f.path, + f.id, + ), + ), + ); + mediaFiles.push(...files.map(f => ({ ...f, draft: true }))); + } + const dataFiles = sortBy( entryData.diffs.filter(d => d.path.endsWith(extension)), f => f.path.length, ); - // if the unpublished entry has no diffs, return the original + let data = ''; + let entryWithFormat; let newFile = false; let path = slug; + // if the unpublished entry has no diffs, return the original if (dataFiles.length <= 0) { const loadedEntry = await this.implementation.getEntry( selectEntryPath(collection, slug) as string, ); data = loadedEntry.data; path = loadedEntry.file.path; + } else if (collection.get('multi_content_diff_files')) { + data = await Promise.all( + dataFiles.map(async file => { + const data = await this.implementation.unpublishedEntryDataFile( + collection.get('name'), + entryData.slug, + file.path, + file.id, + ); + return { data, path: file.path }; + }), + ); + newFile = dataFiles[0].newFile; + path = dataFiles[0].path; } else { const entryFile = dataFiles[0]; data = await this.implementation.unpublishedEntryDataFile( @@ -876,32 +910,38 @@ export class Backend { path = entryFile.path; } - const mediaFiles: MediaFile[] = []; - if (withMediaFiles) { - const nonDataFiles = entryData.diffs.filter(d => !d.path.endsWith(extension)); - const files = await Promise.all( - nonDataFiles.map(f => - this.implementation!.unpublishedEntryMediaFile( - collection.get('name'), - slug, - f.path, - f.id, - ), - ), - ); - mediaFiles.push(...files.map(f => ({ ...f, draft: true }))); + if (Array.isArray(data)) { + const multipleEntries = data.map(d => { + return this.entryWithFormat(collection)( + createEntry(collection.get('name'), slug, path, { + raw: d.data, + isModification: !newFile, + label: collection && selectFileEntryLabel(collection, slug), + mediaFiles, + updatedOn: entryData.updatedAt, + status: entryData.status, + meta: { path: prepareMetaPath(path, collection) }, + i18nStructure: collection.get('i18n_structure'), + slugWithLocale: selectEntrySlug(collection, d.path), + }), + ); + }); + + entryWithFormat = this.combineEntries(multipleEntries); + } else { + const entry = createEntry(collection.get('name'), slug, path, { + raw: data, + isModification: !newFile, + label: collection && selectFileEntryLabel(collection, slug), + mediaFiles, + updatedOn: entryData.updatedAt, + status: entryData.status, + meta: { path: prepareMetaPath(path, collection) }, + }); + + entryWithFormat = this.entryWithFormat(collection)(entry); } - const entry = createEntry(collection.get('name'), slug, path, { - raw: data, - isModification: !newFile, - label: collection && selectFileEntryLabel(collection, slug), - mediaFiles, - updatedOn: entryData.updatedAt, - status: entryData.status, - meta: { path: prepareMetaPath(path, collection) }, - }); - const entryWithFormat = this.entryWithFormat(collection)(entry); return entryWithFormat; } @@ -1066,8 +1106,6 @@ export class Backend { const entryDraft = (modifiedData && draft.setIn(['entry', 'data'], modifiedData)) || draft; const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; - const MultiContentDiffFiles = - DIFF_FILE_TYPES.includes(collection.get('i18n_structure')) && collection.get('multi_content'); const useWorkflow = selectUseWorkflow(config); @@ -1122,7 +1160,7 @@ export class Backend { } let entriesObj = [entryObj]; - if (MultiContentDiffFiles) { + if (collection.get('multi_content_diff_files')) { entriesObj = this.getMultipleEntries(collection, entryDraft, entryObj); } @@ -1249,8 +1287,6 @@ export class Backend { const config = state.config; const path = selectEntryPath(collection, slug) as string; const extension = selectFolderEntryExtension(collection) as string; - const MultiContentDiffFiles = - DIFF_FILE_TYPES.includes(collection.get('i18n_structure')) && collection.get('multi_content'); const locales = collection.get('locales'); if (!selectAllowDeletion(collection)) { @@ -1273,7 +1309,7 @@ export class Backend { const entry = selectEntry(state.entries, collection.get('name'), slug); await this.invokePreUnpublishEvent(entry); - if (MultiContentDiffFiles) { + if (collection.get('multi_content_diff_files')) { const i18nStructure = collection.get('i18n_structure'); if (i18nStructure === LOCALE_FILE_EXTENSIONS) { for (const l of locales) { diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js index 558b7d7651c5..ed2ee887f6c3 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js @@ -111,6 +111,20 @@ export const ControlHint = styled.p` transition: color ${transitions.main}; `; +const LocaleDropdown = ({ locales, selectedLocale, onLocaleChange }) => { + return ( + {selectedLocale}} + dropdownTopOverlap="30px" + dropdownWidth="100px" + > + {locales.map(l => ( + onLocaleChange(l)} /> + ))} + + ); +}; + class EditorControl extends React.Component { static propTypes = { value: PropTypes.oneOfType([ @@ -197,7 +211,6 @@ class EditorControl extends React.Component { isEditorComponent, isNewEditorComponent, parentIds, - locales, selectedLocale, onLocaleChange, t, @@ -216,23 +229,16 @@ class EditorControl extends React.Component { const hasErrors = !!errors || childErrors; const multiContentWidgetId = field.get('multiContentId') === Symbol.for('multiContentId'); const locales = this.props.collection.get('locales'); - const label = ( - <> - {locales && multiContentWidgetId ? ( - {selectedLocale}} - dropdownTopOverlap="30px" - dropdownWidth="100px" - > - {locales.map(l => ( - onLocaleChange(l)} /> - ))} - - ) : ( - `${field.get('label', field.get('name'))}` - )} - - ); + const label = + locales && multiContentWidgetId ? ( + + ) : ( + `${field.get('label', field.get('name'))}` + ); return ( diff --git a/packages/netlify-cms-core/src/constants/multiContentTypes.js b/packages/netlify-cms-core/src/constants/multiContentTypes.js index 4759f30bf50f..b4f6e49ec250 100644 --- a/packages/netlify-cms-core/src/constants/multiContentTypes.js +++ b/packages/netlify-cms-core/src/constants/multiContentTypes.js @@ -2,7 +2,6 @@ export const SINGLE_FILE = 'single_file'; export const LOCALE_FILE_EXTENSIONS = 'locale_file_extensions'; export const LOCALE_FOLDERS = 'locale_folders'; export const DIFF_FILE_TYPES = [LOCALE_FILE_EXTENSIONS, LOCALE_FOLDERS]; -export const NON_TRANSLATABLE_FIELDS = ['date', 'datetime', 'map', 'code', 'number']; export const locales = [ 'aa', diff --git a/packages/netlify-cms-core/src/valueObjects/Entry.ts b/packages/netlify-cms-core/src/valueObjects/Entry.ts index 056baa03c3ae..b50ea6e4dc34 100644 --- a/packages/netlify-cms-core/src/valueObjects/Entry.ts +++ b/packages/netlify-cms-core/src/valueObjects/Entry.ts @@ -32,6 +32,7 @@ export interface EntryValue { meta: { path?: string }; i18nStructure?: string; contentKey?: string; + slugWithLocale?: string; } export function createEntry(collection: string, slug = '', path = '', options: Options = {}) { @@ -55,6 +56,9 @@ export function createEntry(collection: string, slug = '', path = '', options: O ...(options.i18nStructure && { i18nStructure: options.i18nStructure, }), + ...(options.slugWithLocale && { + slugWithLocale: options.slugWithLocale, + }), }; return returnObj; From 89159dea9bcd2ae05ca9e244661857a4889d5e3d Mon Sep 17 00:00:00 2001 From: barthc Date: Wed, 24 Jun 2020 22:33:15 +0100 Subject: [PATCH 15/23] fix: type check errors --- dev-test/config.yml | 24 +-------- .../netlify-cms-backend-bitbucket/src/API.ts | 3 +- .../src/implementation.ts | 4 +- .../netlify-cms-backend-github/src/API.ts | 5 +- .../netlify-cms-backend-gitlab/src/API.ts | 3 +- .../src/implementation.ts | 4 +- .../src/implementation.ts | 2 +- .../src/actions/editorialWorkflow.ts | 2 - .../netlify-cms-core/src/actions/entries.ts | 2 - packages/netlify-cms-core/src/backend.ts | 53 ++++++++++--------- .../src/constants/configSchema.js | 1 + .../netlify-cms-core/src/types/immutable.ts | 1 + packages/netlify-cms-core/src/types/redux.ts | 4 ++ .../src/valueObjects/Entry.ts | 3 ++ .../src/implementation.ts | 6 ++- 15 files changed, 55 insertions(+), 62 deletions(-) diff --git a/dev-test/config.yml b/dev-test/config.yml index 0361bf29e9bd..2dd82bcac504 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -1,16 +1,11 @@ backend: - name: github - repo: bastiaan02/jekyll-base + name: test-repo site_url: https://example.com publish_mode: editorial_workflow media_folder: assets/uploads -locales: - - en - - fr - collections: # A list of collections the CMS should be able to edit - name: 'posts' # Used in routes, ie.: /admin/collections/:slug/edit label: 'Posts' # Used in the UI @@ -50,22 +45,7 @@ collections: # A list of collections the CMS should be able to edit tagname: '' - { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' } - - name: 'blog' # Used in routes, ie.: /admin/collections/:slug/edit - label: 'BLOG' # Used in the UI - folder: '_blogs' - i18n_structure: locale_file_extensions - create: true # Allow users to create new documents in this collection - fields: # The fields each document in this collection have - - { label: 'Title', name: 'title', widget: 'string', translatable: true, tagname: 'h1' } - - { label: 'Answer', name: 'body', widget: 'markdown', translatable: true } - - name: 'product' # Used in routes, ie.: /admin/collections/:slug/edit - label: 'PRODUCT' # Used in the UI - folder: '_products' - i18n_structure: locale_folders - create: true # Allow users to create new documents in this collection - fields: # The fields each document in this collection have - - { label: 'Title', name: 'title', widget: 'string', translatable: true, tagname: 'h1' } - - { label: 'Description', name: 'body', widget: 'markdown', translatable: true } + - name: 'faq' # Used in routes, ie.: /admin/collections/:slug/edit label: 'FAQ' # Used in the UI folder: '_faqs' diff --git a/packages/netlify-cms-backend-bitbucket/src/API.ts b/packages/netlify-cms-backend-bitbucket/src/API.ts index 1f71fd7536df..909eb5243fb9 100644 --- a/packages/netlify-cms-backend-bitbucket/src/API.ts +++ b/packages/netlify-cms-backend-bitbucket/src/API.ts @@ -501,8 +501,9 @@ export default class API { async persistFiles(entries: Entry[] | null, mediaFiles: AssetProxy[], options: PersistOptions) { const files = entries ? [...entries, ...mediaFiles] : mediaFiles; + const entry = entries ? entries[0] : null; if (options.useWorkflow) { - return this.editorialWorkflowGit(files, entries[0] as Entry, options); + return this.editorialWorkflowGit(files, entry as Entry, options); } else { return this.uploadFiles(files, { commitMessage: options.commitMessage, branch: this.branch }); } diff --git a/packages/netlify-cms-backend-git-gateway/src/implementation.ts b/packages/netlify-cms-backend-git-gateway/src/implementation.ts index d5e822731f11..638043ebac75 100644 --- a/packages/netlify-cms-backend-git-gateway/src/implementation.ts +++ b/packages/netlify-cms-backend-git-gateway/src/implementation.ts @@ -531,10 +531,10 @@ export default class GitGateway implements Implementation { return this.backend!.getMediaFile(path); } - async persistEntry(entry: Entry, mediaFiles: AssetProxy[], options: PersistOptions) { + async persistEntry(entries: Entry[], mediaFiles: AssetProxy[], options: PersistOptions) { const client = await this.getLargeMediaClient(); return this.backend!.persistEntry( - entry, + entries, client.enabled ? await getLargeMediaFilteredMediaFiles(client, mediaFiles) : mediaFiles, options, ); diff --git a/packages/netlify-cms-backend-github/src/API.ts b/packages/netlify-cms-backend-github/src/API.ts index 1ee0e4ed1f47..805e0cd0f01c 100644 --- a/packages/netlify-cms-backend-github/src/API.ts +++ b/packages/netlify-cms-backend-github/src/API.ts @@ -1,6 +1,6 @@ import { Base64 } from 'js-base64'; import semaphore, { Semaphore } from 'semaphore'; -import { initial, last, partial, result, trimStart, trim, difference } from 'lodash'; +import { initial, last, partial, result, trimStart, trim } from 'lodash'; import { oneLine } from 'common-tags'; import { getAllResponses, @@ -872,6 +872,7 @@ export default class API { async persistFiles(entries: Entry[] | null, mediaFiles: AssetProxy[], options: PersistOptions) { const files = entries ? mediaFiles.concat(entries) : mediaFiles; + const entry = entries ? entries[0] : null; const uploadPromises = files.map(file => this.uploadBlob(file)); await Promise.all(uploadPromises); @@ -891,7 +892,7 @@ export default class API { ); return this.editorialWorkflowGit( files as TreeFile[], - entries[0] as Entry, + entry as Entry, mediaFilesList, options, ); diff --git a/packages/netlify-cms-backend-gitlab/src/API.ts b/packages/netlify-cms-backend-gitlab/src/API.ts index a2cbebfc46c3..3704ccc54fb4 100644 --- a/packages/netlify-cms-backend-gitlab/src/API.ts +++ b/packages/netlify-cms-backend-gitlab/src/API.ts @@ -514,8 +514,9 @@ export default class API { async persistFiles(entries: Entry[] | null, mediaFiles: AssetProxy[], options: PersistOptions) { const files = entries ? [...entries, ...mediaFiles] : mediaFiles; + const entry = entries ? entries[0] : null; if (options.useWorkflow) { - return this.editorialWorkflowGit(files, entries[0] as Entry, options); + return this.editorialWorkflowGit(files, entry as Entry, options); } else { const items = await this.getCommitItems(files, this.branch); return this.uploadAndCommit(items, { diff --git a/packages/netlify-cms-backend-proxy/src/implementation.ts b/packages/netlify-cms-backend-proxy/src/implementation.ts index 2c72f75ec222..256aaefc9548 100644 --- a/packages/netlify-cms-backend-proxy/src/implementation.ts +++ b/packages/netlify-cms-backend-proxy/src/implementation.ts @@ -179,13 +179,13 @@ export default class ProxyBackend implements Implementation { }); } - async persistEntry(entry: Entry, assetProxies: AssetProxy[], options: PersistOptions) { + async persistEntry(entries: Entry[], assetProxies: AssetProxy[], options: PersistOptions) { const assets = await Promise.all(assetProxies.map(serializeAsset)); return this.request({ action: 'persistEntry', params: { branch: this.branch, - entry, + entries, assets, options: { ...options, status: options.status || this.options.initialWorkflowStatus }, }, diff --git a/packages/netlify-cms-backend-test/src/implementation.ts b/packages/netlify-cms-backend-test/src/implementation.ts index b6b14b687438..27759ee133c2 100644 --- a/packages/netlify-cms-backend-test/src/implementation.ts +++ b/packages/netlify-cms-backend-test/src/implementation.ts @@ -295,7 +295,7 @@ export default class TestBackend implements Implementation { }; } - async persistEntry(entries: Entry, assetProxies: AssetProxy[], options: PersistOptions) { + async persistEntry(entries: Entry[], assetProxies: AssetProxy[], options: PersistOptions) { const { path, raw, slug, newPath } = entries[0]; if (options.useWorkflow) { const key = `${options.collectionName}/${slug}`; diff --git a/packages/netlify-cms-core/src/actions/editorialWorkflow.ts b/packages/netlify-cms-core/src/actions/editorialWorkflow.ts index 7704fbd83ecc..6ac9b3cfee59 100644 --- a/packages/netlify-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/netlify-cms-core/src/actions/editorialWorkflow.ts @@ -266,8 +266,6 @@ export function loadUnpublishedEntry(collection: Collection, slug: string) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const backend = currentBackend(state.config); - const multiContent = collection.get('multi_content'); - const i18nStructure = collection.get('i18n_structure'); const entriesLoaded = get(state.editorialWorkflow.toJS(), 'pages.ids', false); //run possible unpublishedEntries migration if (!entriesLoaded) { diff --git a/packages/netlify-cms-core/src/actions/entries.ts b/packages/netlify-cms-core/src/actions/entries.ts index 8515e1854a2a..440e2c4af5a7 100644 --- a/packages/netlify-cms-core/src/actions/entries.ts +++ b/packages/netlify-cms-core/src/actions/entries.ts @@ -454,8 +454,6 @@ export function deleteLocalBackup(collection: Collection, slug: string) { export function loadEntry(collection: Collection, slug: string) { return async (dispatch: ThunkDispatch, getState: () => State) => { - const locales = getState().config.get('locales'); - const multiContent = collection.get('multi_content'); await waitForMediaLibraryToLoad(dispatch, getState()); dispatch(entryLoading(collection, slug)); diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index d07303f42f76..cce46ac22677 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -48,7 +48,6 @@ import { FilterRule, Collections, EntryDraft, - Entry, CollectionFile, State, EntryField, @@ -237,6 +236,13 @@ interface PersistArgs { status?: string; } +interface EntryObj { + path: string; + slug: string; + raw: string; + newPath?: string; +} + interface ImplementationInitOptions { useWorkflow: boolean; updateUserCredentials: (credentials: Credentials) => void; @@ -494,7 +500,7 @@ export class Backend { // repeats the process. Once there is no available "next" action, it // returns all the collected entries. Used to retrieve all entries // for local searches and queries. - async listAllEntries(collection: Collection, depth: number) { + async listAllEntries(collection: Collection, depth: number | null = null) { const selectedDepth = depth || collection.get('nested')?.get('depth') || @@ -518,10 +524,10 @@ export class Backend { return entries; } - async listAllMultipleEntires(collection: Collection, page: number) { + async listAllMultipleEntires(collection: Collection) { const i18nStructure = collection.get('i18n_structure'); - const locales = collection.get('locales'); - const depth = i18nStructure === LOCALE_FOLDERS ? 2 : ''; + const locales = collection.get('locales') as string[]; + const depth = i18nStructure === LOCALE_FOLDERS ? 2 : null; const entries = await this.listAllEntries(collection, depth); let multiEntries; if (i18nStructure === LOCALE_FILE_EXTENSIONS) { @@ -552,7 +558,7 @@ export class Backend { }); } - return { entries: this.combineMultipleContentEntries(multiEntries) }; + return { entries: this.combineMultipleContentEntries(multiEntries as EntryValue[]) }; } async search(collections: Collection[], searchTerm: string) { @@ -873,7 +879,7 @@ export class Backend { f => f.path.length, ); - let data = ''; + let data; let entryWithFormat; let newFile = false; let path = slug; @@ -992,26 +998,26 @@ export class Backend { return entry; } - combineMultipleContentEntries(entries: entryMap[]) { + combineMultipleContentEntries(entries: EntryValue[]) { const groupEntries = groupBy(entries, 'contentKey'); - return Object.keys(groupEntries).reduce((acc, key) => { + return Object.keys(groupEntries).reduce((acc: EntryValue[], key: string) => { const entries = groupEntries[key]; return [...acc, this.combineEntries(entries)]; }, []); } - combineEntries(entries: entryMap[]) { + combineEntries(entries: EntryValue[]) { const { i18nStructure, contentKey, slugWithLocale, ...entry } = entries[0]; - const data = {}; - let splitChar; - let path; - entries.forEach(e => { + const data: { [key: string]: any } = {}; + let path = ''; + let locale; + entries.forEach((e: EntryValue) => { if (i18nStructure === LOCALE_FILE_EXTENSIONS) { - const locale = e.slugWithLocale.slice(-2); + locale = e?.slugWithLocale?.slice(-2) as string; !path && (path = e.path.replace(`.${locale}`, '')); data[locale] = e.data; } else if (i18nStructure === LOCALE_FOLDERS) { - const locale = e.slugWithLocale.slice(0, 2); + locale = e?.slugWithLocale?.slice(0, 2) as string; !path && (path = e.path.replace(`${locale}/`, '')); data[locale] = e.data; } @@ -1109,12 +1115,7 @@ export class Backend { const useWorkflow = selectUseWorkflow(config); - let entryObj: { - path: string; - slug: string; - raw: string; - newPath?: string; - }; + let entryObj: EntryObj; const customPath = selectCustomPath(collection, entryDraft); @@ -1204,12 +1205,12 @@ export class Backend { return entryObj.slug; } - getMultipleEntries(collection: Collection, entryDraft: EntryDraft, entryObj: Entry) { + getMultipleEntries(collection: Collection, entryDraft: EntryDraft, entryObj: EntryObj) { const i18nStructure = collection.get('i18n_structure'); const extension = selectFolderEntryExtension(collection); const data = entryDraft.getIn(['entry', 'data']).toJS(); const locales = Object.keys(data); - const entriesObj = []; + const entriesObj: EntryObj[] = []; if (i18nStructure === LOCALE_FILE_EXTENSIONS) { locales.forEach(l => { entriesObj.push({ @@ -1287,7 +1288,7 @@ export class Backend { const config = state.config; const path = selectEntryPath(collection, slug) as string; const extension = selectFolderEntryExtension(collection) as string; - const locales = collection.get('locales'); + const locales = collection.get('locales') as string[]; if (!selectAllowDeletion(collection)) { throw new Error('Not allowed to delete entries in this collection'); @@ -1310,7 +1311,7 @@ export class Backend { const entry = selectEntry(state.entries, collection.get('name'), slug); await this.invokePreUnpublishEvent(entry); if (collection.get('multi_content_diff_files')) { - const i18nStructure = collection.get('i18n_structure'); + const i18nStructure = collection.get('i18n_structure') as string; if (i18nStructure === LOCALE_FILE_EXTENSIONS) { for (const l of locales) { await this.implementation diff --git a/packages/netlify-cms-core/src/constants/configSchema.js b/packages/netlify-cms-core/src/constants/configSchema.js index 42ee023ce686..8c6d573982af 100644 --- a/packages/netlify-cms-core/src/constants/configSchema.js +++ b/packages/netlify-cms-core/src/constants/configSchema.js @@ -26,6 +26,7 @@ const fieldsConfig = () => ({ label: { type: 'string' }, widget: { type: 'string' }, required: { type: 'boolean' }, + translatable: { type: 'boolean' }, hint: { type: 'string' }, pattern: { type: 'array', diff --git a/packages/netlify-cms-core/src/types/immutable.ts b/packages/netlify-cms-core/src/types/immutable.ts index 3b6ffdcefc44..ae2c89d511de 100644 --- a/packages/netlify-cms-core/src/types/immutable.ts +++ b/packages/netlify-cms-core/src/types/immutable.ts @@ -3,6 +3,7 @@ export interface StaticallyTypedRecord { set(key: K, value: V): StaticallyTypedRecord & T; has(key: K): boolean; delete(key: K): StaticallyTypedRecord; + mergeDeep(value: T): StaticallyTypedRecord; getIn( keys: [K1, K2], defaultValue?: V, diff --git a/packages/netlify-cms-core/src/types/redux.ts b/packages/netlify-cms-core/src/types/redux.ts index 3e04b1fe7643..88021768cc60 100644 --- a/packages/netlify-cms-core/src/types/redux.ts +++ b/packages/netlify-cms-core/src/types/redux.ts @@ -186,6 +186,10 @@ type CollectionObject = { view_filters: List>; nested?: Nested; meta?: Meta; + locales?: string[]; + multi_content?: boolean; + multi_content_diff_files?: boolean; + i18n_structure?: string; }; export type Collection = StaticallyTypedRecord; diff --git a/packages/netlify-cms-core/src/valueObjects/Entry.ts b/packages/netlify-cms-core/src/valueObjects/Entry.ts index b50ea6e4dc34..32f83ee8b9de 100644 --- a/packages/netlify-cms-core/src/valueObjects/Entry.ts +++ b/packages/netlify-cms-core/src/valueObjects/Entry.ts @@ -13,6 +13,9 @@ interface Options { updatedOn?: string; status?: string; meta?: { path?: string }; + i18nStructure?: string; + contentKey?: string; + slugWithLocale?: string; } export interface EntryValue { diff --git a/packages/netlify-cms-lib-util/src/implementation.ts b/packages/netlify-cms-lib-util/src/implementation.ts index e6db07bf2e4e..8942c8715794 100644 --- a/packages/netlify-cms-lib-util/src/implementation.ts +++ b/packages/netlify-cms-lib-util/src/implementation.ts @@ -115,7 +115,11 @@ export interface Implementation { getMedia: (folder?: string) => Promise; getMediaFile: (path: string) => Promise; - persistEntry: (obj: Entry, assetProxies: AssetProxy[], opts: PersistOptions) => Promise; + persistEntry: ( + entries: Entry[], + assetProxies: AssetProxy[], + opts: PersistOptions, + ) => Promise; persistMedia: (file: AssetProxy, opts: PersistOptions) => Promise; deleteFile: (path: string, commitMessage: string) => Promise; From b0953484e894cca9144bd2cde03823a98786f236 Mon Sep 17 00:00:00 2001 From: barthc Date: Thu, 25 Jun 2020 16:52:29 +0100 Subject: [PATCH 16/23] fix: proxy backend --- packages/netlify-cms-core/src/backend.ts | 18 ++++++++++++------ packages/netlify-cms-core/src/types/redux.ts | 1 + .../src/middlewares/joi/index.spec.ts | 10 +++++----- .../src/middlewares/joi/index.ts | 16 ++++++++++------ .../src/middlewares/localFs/index.ts | 10 ++++++---- .../src/middlewares/localGit/index.ts | 18 ++++++++++-------- .../src/middlewares/types.ts | 2 +- 7 files changed, 45 insertions(+), 30 deletions(-) diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index cce46ac22677..37071dd0c7cb 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -447,7 +447,7 @@ export class Backend { return filteredEntries; } - async listEntries(collection: Collection, depth: number) { + async listEntries(collection: Collection, depth: number | null = null) { const extension = selectFolderEntryExtension(collection); let listMethod: () => Promise; const collectionType = collection.get('type'); @@ -1013,11 +1013,11 @@ export class Backend { let locale; entries.forEach((e: EntryValue) => { if (i18nStructure === LOCALE_FILE_EXTENSIONS) { - locale = e?.slugWithLocale?.slice(-2) as string; + locale = e!.slugWithLocale!.slice(-2) as string; !path && (path = e.path.replace(`.${locale}`, '')); data[locale] = e.data; } else if (i18nStructure === LOCALE_FOLDERS) { - locale = e?.slugWithLocale?.slice(0, 2) as string; + locale = e!.slugWithLocale!.slice(0, 2) as string; !path && (path = e.path.replace(`${locale}/`, '')); data[locale] = e.data; } @@ -1209,7 +1209,7 @@ export class Backend { const i18nStructure = collection.get('i18n_structure'); const extension = selectFolderEntryExtension(collection); const data = entryDraft.getIn(['entry', 'data']).toJS(); - const locales = Object.keys(data); + const locales = uniq([collection.get('default_locale'), ...Object.keys(data)]); const entriesObj: EntryObj[] = []; if (i18nStructure === LOCALE_FILE_EXTENSIONS) { locales.forEach(l => { @@ -1218,8 +1218,11 @@ export class Backend { slug: entryObj.slug, raw: this.entryToRaw( collection, - entryDraft.get('entry').set('data', entryDraft.getIn(['entry', 'data', l])), + entryDraft.get('entry').set('data', entryDraft.getIn(['entry', 'data', l!])), ), + ...(entryObj.newPath && { + newPath: entryObj.newPath, + }), }); }); } else if (i18nStructure === LOCALE_FOLDERS) { @@ -1229,8 +1232,11 @@ export class Backend { slug: entryObj.slug, raw: this.entryToRaw( collection, - entryDraft.get('entry').set('data', entryDraft.getIn(['entry', 'data', l])), + entryDraft.get('entry').set('data', entryDraft.getIn(['entry', 'data', l!])), ), + ...(entryObj.newPath && { + newPath: entryObj.newPath, + }), }); }); } diff --git a/packages/netlify-cms-core/src/types/redux.ts b/packages/netlify-cms-core/src/types/redux.ts index 88021768cc60..20875a3c1125 100644 --- a/packages/netlify-cms-core/src/types/redux.ts +++ b/packages/netlify-cms-core/src/types/redux.ts @@ -187,6 +187,7 @@ type CollectionObject = { nested?: Nested; meta?: Meta; locales?: string[]; + default_locale?: string; multi_content?: boolean; multi_content_diff_files?: boolean; i18n_structure?: string; diff --git a/packages/netlify-cms-proxy-server/src/middlewares/joi/index.spec.ts b/packages/netlify-cms-proxy-server/src/middlewares/joi/index.spec.ts index a044c8133894..e99092377e24 100644 --- a/packages/netlify-cms-proxy-server/src/middlewares/joi/index.spec.ts +++ b/packages/netlify-cms-proxy-server/src/middlewares/joi/index.spec.ts @@ -275,12 +275,12 @@ describe('defaultSchema', () => { assetFailure( schema.validate({ action: 'persistEntry', params: { ...defaultParams } }), - '"params.entry" is required', + '"params.entries" is required', ); assetFailure( schema.validate({ action: 'persistEntry', - params: { ...defaultParams, entry: { slug: 'slug', path: 'path', raw: 'content' } }, + params: { ...defaultParams, entries: [{ slug: 'slug', path: 'path', raw: 'content' }] }, }), '"params.assets" is required', ); @@ -289,7 +289,7 @@ describe('defaultSchema', () => { action: 'persistEntry', params: { ...defaultParams, - entry: { slug: 'slug', path: 'path', raw: 'content' }, + entries: [{ slug: 'slug', path: 'path', raw: 'content' }], assets: [], }, }), @@ -300,7 +300,7 @@ describe('defaultSchema', () => { action: 'persistEntry', params: { ...defaultParams, - entry: { slug: 'slug', path: 'path', raw: 'content' }, + entries: [{ slug: 'slug', path: 'path', raw: 'content' }], assets: [], options: {}, }, @@ -315,7 +315,7 @@ describe('defaultSchema', () => { action: 'persistEntry', params: { ...defaultParams, - entry: { slug: 'slug', path: 'path', raw: 'content' }, + entries: [{ slug: 'slug', path: 'path', raw: 'content' }], assets: [{ path: 'path', content: 'content', encoding: 'base64' }], options: { commitMessage: 'commitMessage', diff --git a/packages/netlify-cms-proxy-server/src/middlewares/joi/index.ts b/packages/netlify-cms-proxy-server/src/middlewares/joi/index.ts index 6a609430a573..f0f1bb39c117 100644 --- a/packages/netlify-cms-proxy-server/src/middlewares/joi/index.ts +++ b/packages/netlify-cms-proxy-server/src/middlewares/joi/index.ts @@ -39,6 +39,13 @@ export const defaultSchema = ({ path = requiredString } = {}) => { encoding: requiredString.valid('base64'), }); + const entry = Joi.object({ + slug: requiredString, + path, + raw: requiredString, + newPath: path.optional(), + }); + const params = Joi.when('action', { switch: [ { @@ -120,12 +127,9 @@ export const defaultSchema = ({ path = requiredString } = {}) => { is: 'persistEntry', then: defaultParams .keys({ - entry: Joi.object({ - slug: requiredString, - path, - raw: requiredString, - newPath: path.optional(), - }).required(), + entries: Joi.array() + .items(entry) + .required(), assets: Joi.array() .items(asset) .required(), diff --git a/packages/netlify-cms-proxy-server/src/middlewares/localFs/index.ts b/packages/netlify-cms-proxy-server/src/middlewares/localFs/index.ts index 26c7a8c02ace..d904a31468d1 100644 --- a/packages/netlify-cms-proxy-server/src/middlewares/localFs/index.ts +++ b/packages/netlify-cms-proxy-server/src/middlewares/localFs/index.ts @@ -61,16 +61,18 @@ export const localFsMiddleware = ({ repoPath, logger }: FsOptions) => { break; } case 'persistEntry': { - const { entry, assets } = body.params as PersistEntryParams; - await writeFile(path.join(repoPath, entry.path), entry.raw); + const { entries, assets } = body.params as PersistEntryParams; + await Promise.all(entries.map(e => writeFile(path.join(repoPath, e.path), e.raw))); // save assets await Promise.all( assets.map(a => writeFile(path.join(repoPath, a.path), Buffer.from(a.content, a.encoding)), ), ); - if (entry.newPath) { - await move(path.join(repoPath, entry.path), path.join(repoPath, entry.newPath)); + if (entries.every(e => e.newPath)) { + await Promise.all( + entries.map(e => move(path.join(repoPath, e.path), path.join(repoPath, e.newPath!))), + ); } res.json({ message: 'entry persisted' }); break; diff --git a/packages/netlify-cms-proxy-server/src/middlewares/localGit/index.ts b/packages/netlify-cms-proxy-server/src/middlewares/localGit/index.ts index 67e67d59671c..c62062053a79 100644 --- a/packages/netlify-cms-proxy-server/src/middlewares/localGit/index.ts +++ b/packages/netlify-cms-proxy-server/src/middlewares/localGit/index.ts @@ -74,18 +74,20 @@ type GitOptions = { const commitEntry = async ( git: simpleGit.SimpleGit, repoPath: string, - entry: Entry, + entries: Entry[], assets: Asset[], commitMessage: string, ) => { // save entry content - await writeFile(path.join(repoPath, entry.path), entry.raw); + await Promise.all(entries.map(e => writeFile(path.join(repoPath, e.path), e.raw))); // save assets await Promise.all( assets.map(a => writeFile(path.join(repoPath, a.path), Buffer.from(a.content, a.encoding))), ); - if (entry.newPath) { - await move(path.join(repoPath, entry.path), path.join(repoPath, entry.newPath)); + if (entries.every(e => e.newPath)) { + await Promise.all( + entries.map(e => move(path.join(repoPath, e.path), path.join(repoPath, e.newPath!))), + ); } // commits files @@ -274,13 +276,13 @@ export const localGitMiddleware = ({ repoPath, logger }: GitOptions) => { break; } case 'persistEntry': { - const { entry, assets, options } = body.params as PersistEntryParams; + const { entries, assets, options } = body.params as PersistEntryParams; if (!options.useWorkflow) { await runOnBranch(git, branch, async () => { - await commitEntry(git, repoPath, entry, assets, options.commitMessage); + await commitEntry(git, repoPath, entries, assets, options.commitMessage); }); } else { - const slug = entry.slug; + const slug = entries[0].slug; const collection = options.collectionName as string; const contentKey = generateContentKey(collection, slug); const cmsBranch = branchFromContentKey(contentKey); @@ -298,7 +300,7 @@ export const localGitMiddleware = ({ repoPath, logger }: GitOptions) => { d => d.binary && !assets.map(a => a.path).includes(d.path), ); await Promise.all(toDelete.map(f => fs.unlink(path.join(repoPath, f.path)))); - await commitEntry(git, repoPath, entry, assets, options.commitMessage); + await commitEntry(git, repoPath, entries, assets, options.commitMessage); // add status for new entries if (!branchExists) { diff --git a/packages/netlify-cms-proxy-server/src/middlewares/types.ts b/packages/netlify-cms-proxy-server/src/middlewares/types.ts index 3207a5f18514..4050fd877ca4 100644 --- a/packages/netlify-cms-proxy-server/src/middlewares/types.ts +++ b/packages/netlify-cms-proxy-server/src/middlewares/types.ts @@ -57,7 +57,7 @@ export type Entry = { slug: string; path: string; raw: string; newPath?: string export type Asset = { path: string; content: string; encoding: 'base64' }; export type PersistEntryParams = { - entry: Entry; + entries: Entry[]; assets: Asset[]; options: { collectionName?: string; From 6aa500bc877a1d6787e85ec46e21982f80b6ab8c Mon Sep 17 00:00:00 2001 From: barthc Date: Fri, 26 Jun 2020 00:34:29 +0100 Subject: [PATCH 17/23] fix: entries sort filter --- packages/netlify-cms-core/src/actions/entries.ts | 6 ++++-- packages/netlify-cms-core/src/backend.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/netlify-cms-core/src/actions/entries.ts b/packages/netlify-cms-core/src/actions/entries.ts index 440e2c4af5a7..861e16fdc2e4 100644 --- a/packages/netlify-cms-core/src/actions/entries.ts +++ b/packages/netlify-cms-core/src/actions/entries.ts @@ -151,7 +151,9 @@ const getAllEntries = async (state: State, collection: Collection) => { const provider: Backend = integration ? getIntegrationProvider(state.integrations, backend.getToken, integration) : backend; - const entries = await provider.listAllEntries(collection); + const entries = await (collection.get('multi_content_diff_files') + ? provider.listAllMultipleEntires(collection) + : provider.listAllEntries(collection)); return entries; }; @@ -527,7 +529,7 @@ export function loadEntries(collection: Collection, page = 0) { ? // nested collections require all entries to construct the tree provider.listAllEntries(collection).then((entries: EntryValue[]) => ({ entries })) : collection.get('multi_content_diff_files') - ? provider.listAllMultipleEntires(collection) + ? provider.listAllMultipleEntires(collection).then((entries: EntryValue[]) => ({ entries })) : provider.listEntries(collection, page)); response = { ...response, diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index 37071dd0c7cb..20857cff16b5 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -558,7 +558,7 @@ export class Backend { }); } - return { entries: this.combineMultipleContentEntries(multiEntries as EntryValue[]) }; + return this.combineMultipleContentEntries(multiEntries as EntryValue[]); } async search(collections: Collection[], searchTerm: string) { From 16256a027aa9be3bce6e92b411f21454f2b8ab5a Mon Sep 17 00:00:00 2001 From: barthc Date: Tue, 7 Jul 2020 14:16:31 +0100 Subject: [PATCH 18/23] fix: format --- packages/netlify-cms-core/src/__tests__/backend.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/netlify-cms-core/src/__tests__/backend.spec.js b/packages/netlify-cms-core/src/__tests__/backend.spec.js index dd9d519e3c41..100194da8b1f 100644 --- a/packages/netlify-cms-core/src/__tests__/backend.spec.js +++ b/packages/netlify-cms-core/src/__tests__/backend.spec.js @@ -941,7 +941,7 @@ describe('Backend', () => { }); }); - describe('combineMultipleContentEntries', () => { + describe('combineMultipleContentEntries', () => { const implementation = { init: jest.fn(() => implementation), }; From de4d144cd16d339c44c81c718a9f19fda0b72156 Mon Sep 17 00:00:00 2001 From: barthc Date: Tue, 7 Jul 2020 15:04:29 +0100 Subject: [PATCH 19/23] fix: tests --- .../src/__tests__/backend.spec.js | 44 +++++++++---------- packages/netlify-cms-core/src/backend.ts | 10 ++--- 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/packages/netlify-cms-core/src/__tests__/backend.spec.js b/packages/netlify-cms-core/src/__tests__/backend.spec.js index 100194da8b1f..9641168c7f24 100644 --- a/packages/netlify-cms-core/src/__tests__/backend.spec.js +++ b/packages/netlify-cms-core/src/__tests__/backend.spec.js @@ -1036,19 +1036,17 @@ describe('Backend', () => { backend.listAllEntries = jest.fn().mockResolvedValue(entries); - await expect(backend.listAllMultipleEntires(collection)).resolves.toEqual({ - entries: [ - { - slug: 'post', - path: 'posts/post.md', - raw: '', - data: { - en: { title: 'Title en', content: 'Content en' }, - fr: { title: 'Title fr', content: 'Content fr' }, - }, + await expect(backend.listAllMultipleEntires(collection)).resolves.toEqual([ + { + slug: 'post', + path: 'posts/post.md', + raw: '', + data: { + en: { title: 'Title en', content: 'Content en' }, + fr: { title: 'Title fr', content: 'Content fr' }, }, - ], - }); + }, + ]); }); it('should combine multiple content different folder entries', async () => { @@ -1068,19 +1066,17 @@ describe('Backend', () => { backend.listAllEntries = jest.fn().mockResolvedValue(entries); - await expect(backend.listAllMultipleEntires(collection)).resolves.toEqual({ - entries: [ - { - slug: 'post', - path: 'posts/post.md', - raw: '', - data: { - en: { title: 'Title en', content: 'Content en' }, - fr: { title: 'Title fr', content: 'Content fr' }, - }, + await expect(backend.listAllMultipleEntires(collection)).resolves.toEqual([ + { + slug: 'post', + path: 'posts/post.md', + raw: '', + data: { + en: { title: 'Title en', content: 'Content en' }, + fr: { title: 'Title fr', content: 'Content fr' }, }, - ], - }); + }, + ]); }); }); diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index 20857cff16b5..d60576227197 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -1171,8 +1171,8 @@ export class Backend { config, { collection, - slug: entriesObj[0].slug, - path: entriesObj[0].path, + slug: entryObj.slug, + path: entryObj.path, authorLogin: user.login, authorName: user.name, }, @@ -1209,7 +1209,7 @@ export class Backend { const i18nStructure = collection.get('i18n_structure'); const extension = selectFolderEntryExtension(collection); const data = entryDraft.getIn(['entry', 'data']).toJS(); - const locales = uniq([collection.get('default_locale'), ...Object.keys(data)]); + const locales = Object.keys(data); const entriesObj: EntryObj[] = []; if (i18nStructure === LOCALE_FILE_EXTENSIONS) { locales.forEach(l => { @@ -1218,7 +1218,7 @@ export class Backend { slug: entryObj.slug, raw: this.entryToRaw( collection, - entryDraft.get('entry').set('data', entryDraft.getIn(['entry', 'data', l!])), + entryDraft.get('entry').set('data', entryDraft.getIn(['entry', 'data', l])), ), ...(entryObj.newPath && { newPath: entryObj.newPath, @@ -1232,7 +1232,7 @@ export class Backend { slug: entryObj.slug, raw: this.entryToRaw( collection, - entryDraft.get('entry').set('data', entryDraft.getIn(['entry', 'data', l!])), + entryDraft.get('entry').set('data', entryDraft.getIn(['entry', 'data', l])), ), ...(entryObj.newPath && { newPath: entryObj.newPath, From b836fa5287cf016772d5f93e3fea929bcb332c02 Mon Sep 17 00:00:00 2001 From: barthc Date: Thu, 23 Jul 2020 21:07:40 +0100 Subject: [PATCH 20/23] fix: feedback changes --- .../src/implementation.ts | 1 + .../src/__tests__/backend.spec.js | 11 +- .../netlify-cms-core/src/actions/config.js | 25 +-- .../src/actions/editorialWorkflow.ts | 25 ++- .../netlify-cms-core/src/actions/entries.ts | 8 +- packages/netlify-cms-core/src/backend.ts | 133 +++++++----- .../Editor/EditorControlPane/EditorControl.js | 15 +- .../EditorControlPane/EditorControlPane.js | 30 +-- .../src/components/Editor/EditorInterface.js | 16 +- .../src/constants/backendTypes.js | 1 + .../src/constants/configSchema.js | 21 +- .../src/constants/multiContentTypes.js | 197 ++++++++++++++++++ .../src/lib/serializeEntryValues.js | 7 +- .../reducers/__tests__/collections.spec.js | 59 ++++++ .../src/reducers/collections.ts | 82 +++++++- packages/netlify-cms-core/src/types/redux.ts | 6 +- .../src/valueObjects/Entry.ts | 20 +- 17 files changed, 514 insertions(+), 143 deletions(-) create mode 100644 packages/netlify-cms-core/src/constants/backendTypes.js diff --git a/packages/netlify-cms-backend-proxy/src/implementation.ts b/packages/netlify-cms-backend-proxy/src/implementation.ts index 256aaefc9548..4b4a5f440588 100644 --- a/packages/netlify-cms-backend-proxy/src/implementation.ts +++ b/packages/netlify-cms-backend-proxy/src/implementation.ts @@ -185,6 +185,7 @@ export default class ProxyBackend implements Implementation { action: 'persistEntry', params: { branch: this.branch, + entry: entries[0], entries, assets, options: { ...options, status: options.status || this.options.initialWorkflowStatus }, diff --git a/packages/netlify-cms-core/src/__tests__/backend.spec.js b/packages/netlify-cms-core/src/__tests__/backend.spec.js index 9641168c7f24..3fb6bac1dc37 100644 --- a/packages/netlify-cms-core/src/__tests__/backend.spec.js +++ b/packages/netlify-cms-core/src/__tests__/backend.spec.js @@ -941,7 +941,7 @@ describe('Backend', () => { }); }); - describe('combineMultipleContentEntries', () => { + describe('mergeMultipleContentEntries', () => { const implementation = { init: jest.fn(() => implementation), }; @@ -955,16 +955,18 @@ describe('Backend', () => { data: { title: 'Title en', content: 'Content en' }, i18nStructure: 'locale_file_extensions', slugWithLocale: 'post.en', + contentKey: 'posts/post', }, { path: 'posts/post.fr.md', data: { title: 'Title fr', content: 'Content fr' }, i18nStructure: 'locale_file_extensions', slugWithLocale: 'post.fr', + contentKey: 'posts/post', }, ]; - expect(backend.combineMultipleContentEntries(entries)).toEqual([ + expect(backend.mergeMultipleContentEntries(entries)).toEqual([ { path: 'posts/post.md', raw: '', @@ -983,17 +985,18 @@ describe('Backend', () => { data: { title: 'Title en', content: 'Content en' }, i18nStructure: 'locale_folders', slugWithLocale: 'en/post.md', + contentKey: 'posts/post', }, { path: 'posts/fr/post.md', data: { title: 'Title fr', content: 'Content fr' }, i18nStructure: 'locale_folders', slugWithLocale: 'fr/post.md', + contentKey: 'posts/post', }, ]; - const collection = fromJS({ multi_content: 'diff_folder' }); - expect(backend.combineMultipleContentEntries(entries, collection)).toEqual([ + expect(backend.mergeMultipleContentEntries(entries)).toEqual([ { path: 'posts/post.md', raw: '', diff --git a/packages/netlify-cms-core/src/actions/config.js b/packages/netlify-cms-core/src/actions/config.js index 272d3343dcb2..fd46ba0219b0 100644 --- a/packages/netlify-cms-core/src/actions/config.js +++ b/packages/netlify-cms-core/src/actions/config.js @@ -10,7 +10,7 @@ import { selectIdentifier, } from '../reducers/collections'; import { resolveBackend } from 'coreSrc/backend'; -import { DIFF_FILE_TYPES } from 'Constants/multiContentTypes'; +import { TEST } from '../constants/backendTypes'; export const CONFIG_REQUEST = 'CONFIG_REQUEST'; export const CONFIG_SUCCESS = 'CONFIG_SUCCESS'; @@ -123,22 +123,15 @@ export function applyDefaults(config) { // add locale fields collection = collection.set('fields', addLocaleFields(fields, locales)); - collection = collection.set('multi_content', true); - // for test-repo backend single file mode should suffice - if (backend === 'test-repo') { + if (backend === TEST) { collection = collection.set('i18n_structure', 'single_file'); } - if (DIFF_FILE_TYPES.includes(collection.get('i18n_structure'))) { - collection = collection.set('multi_content_diff_files', true); - } } } const files = collection.get('files'); if (files) { - // remove multi_content config if set - collection = collection.delete('multi_content'); collection = collection.delete('nested'); collection = collection.delete('meta'); collection = collection.set( @@ -180,7 +173,7 @@ export function applyDefaults(config) { export function addLocaleFields(fields, locales) { const defaultLocale = locales[0]; - const stripedFields = stripNonTranslatableFields(fields); + const stripedFields = tagNonTranslatableFields(fields); return locales.reduce((acc, item) => { const selectedFields = item === defaultLocale ? fields : stripedFields; return acc.push( @@ -195,23 +188,19 @@ export function addLocaleFields(fields, locales) { }, List()); } -function stripNonTranslatableFields(fields) { +function tagNonTranslatableFields(fields) { return fields.reduce((acc, item) => { const subfields = item.get('field') || item.get('fields'); if (List.isList(subfields)) { - return acc.push(item.set('fields', stripNonTranslatableFields(subfields))); + return acc.push(item.set('fields', tagNonTranslatableFields(subfields))); } if (Map.isMap(subfields)) { - return acc.push(item.set('field', stripNonTranslatableFields([subfields]))); - } - - if (item.get('translatable')) { - return acc.push(item); + return acc.push(item.set('field', tagNonTranslatableFields([subfields]).first())); } - return acc; + return acc.push(item.get('translatable') ? item : item.set('translatable', false)); }, List()); } diff --git a/packages/netlify-cms-core/src/actions/editorialWorkflow.ts b/packages/netlify-cms-core/src/actions/editorialWorkflow.ts index 6ac9b3cfee59..c5aa333f3a90 100644 --- a/packages/netlify-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/netlify-cms-core/src/actions/editorialWorkflow.ts @@ -13,7 +13,7 @@ import { selectUnpublishedEntry, } from '../reducers'; import { selectEditingDraft } from '../reducers/entries'; -import { selectFields } from '../reducers/collections'; +import { selectFields, hasMultiContentDiffFiles } from '../reducers/collections'; import { EDITORIAL_WORKFLOW, status, Status } from '../constants/publishModes'; import { EDITORIAL_WORKFLOW_ERROR } from 'netlify-cms-lib-util'; import { @@ -293,18 +293,18 @@ export function loadUnpublishedEntry(collection: Collection, slug: string) { ); dispatch(addAssets(assetProxies)); - if (collection.get('multi_content_diff_files')) { + if (hasMultiContentDiffFiles(collection)) { const publishedEntries = get( getState().entries.toJS(), `pages.${collection.get('name')}.ids`, false, ); !publishedEntries && (await dispatch(loadEntry(collection, slug))); - - if (entry.isModification) { - const publishedEntry = selectEntry(getState(), collection.get('name'), slug); - entry = publishedEntry.mergeDeep(fromJS(entry)).toJS(); - } + const publishedEntry = selectEntry(getState(), collection.get('name'), slug); + publishedEntry && + entry.isModification === false && + (entry = { ...entry, isModification: true }); + entry.isModification && (entry = publishedEntry.mergeDeep(fromJS(entry)).toJS()); } dispatch(unpublishedEntryLoaded(collection, entry)); @@ -339,7 +339,14 @@ export function loadUnpublishedEntries(collections: Collections) { dispatch(unpublishedEntriesLoading()); backend .unpublishedEntries(collections) - .then(response => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination))) + .then(response => { + const entries = response.entries; + const multiContentEntries = entries.filter(e => e.multiContent); + dispatch(unpublishedEntriesLoaded(entries, response.pagination)); + multiContentEntries.forEach(entry => { + dispatch(loadUnpublishedEntry(collections.get(entry.collection), entry.slug)); + }); + }) .catch((error: Error) => { dispatch( notifSend({ @@ -402,7 +409,7 @@ export function persistUnpublishedEntry(collection: Collection, existingUnpublis * update the entry and entryDraft with the serialized values. */ const fields = selectFields(collection, entry.get('slug')); - const serializedData = serializeValues(entry.get('data'), fields); + const serializedData = serializeValues(collection, entry.get('data'), fields); const serializedEntry = entry.set('data', serializedData); const serializedEntryDraft = entryDraft.set('entry', serializedEntry); diff --git a/packages/netlify-cms-core/src/actions/entries.ts b/packages/netlify-cms-core/src/actions/entries.ts index 861e16fdc2e4..11f3261e7052 100644 --- a/packages/netlify-cms-core/src/actions/entries.ts +++ b/packages/netlify-cms-core/src/actions/entries.ts @@ -5,7 +5,7 @@ import { serializeValues } from '../lib/serializeEntryValues'; import { currentBackend, Backend } from '../backend'; import { getIntegrationProvider } from '../integrations'; import { selectIntegration, selectPublishedSlugs } from '../reducers'; -import { selectFields, updateFieldByKey } from '../reducers/collections'; +import { selectFields, updateFieldByKey, hasMultiContentDiffFiles } from '../reducers/collections'; import { selectCollectionEntriesCursor } from '../reducers/cursors'; import { Cursor, ImplementationMediaFile } from 'netlify-cms-lib-util'; import { createEntry, EntryValue } from '../valueObjects/Entry'; @@ -151,7 +151,7 @@ const getAllEntries = async (state: State, collection: Collection) => { const provider: Backend = integration ? getIntegrationProvider(state.integrations, backend.getToken, integration) : backend; - const entries = await (collection.get('multi_content_diff_files') + const entries = await (hasMultiContentDiffFiles(collection) ? provider.listAllMultipleEntires(collection) : provider.listAllEntries(collection)); return entries; @@ -528,7 +528,7 @@ export function loadEntries(collection: Collection, page = 0) { } = await (collection.has('nested') ? // nested collections require all entries to construct the tree provider.listAllEntries(collection).then((entries: EntryValue[]) => ({ entries })) - : collection.get('multi_content_diff_files') + : hasMultiContentDiffFiles(collection) ? provider.listAllMultipleEntires(collection).then((entries: EntryValue[]) => ({ entries })) : provider.listEntries(collection, page)); response = { @@ -792,7 +792,7 @@ export function persistEntry(collection: Collection) { * update the entry and entryDraft with the serialized values. */ const fields = selectFields(collection, entry.get('slug')); - const serializedData = serializeValues(entryDraft.getIn(['entry', 'data']), fields); + const serializedData = serializeValues(collection, entryDraft.getIn(['entry', 'data']), fields); const serializedEntry = entry.set('data', serializedData); const serializedEntryDraft = entryDraft.set('entry', serializedEntry); dispatch(entryPersisting(collection, serializedEntry)); diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index d60576227197..cc3987b686d8 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -16,8 +16,10 @@ import { selectMediaFolders, selectFieldsComments, selectHasMetaPath, + hasMultiContent, + hasMultiContentDiffFiles, } from './reducers/collections'; -import { createEntry, EntryValue } from './valueObjects/Entry'; +import { createEntry, EntryValue, MultiContentArgs } from './valueObjects/Entry'; import { sanitizeChar } from './lib/urlHelper'; import { getBackend, invokeEvent } from './lib/registry'; import { commitMessageFormatter, slugFormatter, previewUrlFormatter } from './lib/formatters'; @@ -261,6 +263,24 @@ const prepareMetaPath = (path: string, collection: Collection) => { return dir.substr(collection.get('folder')!.length + 1) || '/'; }; +const entryLocale = (collection: Collection, slug: string) => { + return collection.get('i18n_structure') === LOCALE_FILE_EXTENSIONS + ? slug.split('.').pop() + : slug.split('/').shift(); +}; + +const collectionDepth = (collection: Collection) => { + let depth; + depth = + collection.get('nested')?.get('depth') || getPathDepth(collection.get('path', '') as string); + + if (hasMultiContent(collection) && collection.get('i18n_structure') === LOCALE_FOLDERS) { + depth = 2; + } + + return depth; +}; + export class Backend { implementation: Implementation; backendName: string; @@ -447,21 +467,17 @@ export class Backend { return filteredEntries; } - async listEntries(collection: Collection, depth: number | null = null) { + async listEntries(collection: Collection) { const extension = selectFolderEntryExtension(collection); let listMethod: () => Promise; const collectionType = collection.get('type'); if (collectionType === FOLDER) { - const selectedDepth = - depth || - collection.get('nested')?.get('depth') || - getPathDepth(collection.get('path', '') as string); - + const depth = collectionDepth(collection); listMethod = () => { return this.implementation.entriesByFolder( collection.get('folder') as string, extension, - selectedDepth, + depth, ); }; } else if (collectionType === FILES) { @@ -500,20 +516,16 @@ export class Backend { // repeats the process. Once there is no available "next" action, it // returns all the collected entries. Used to retrieve all entries // for local searches and queries. - async listAllEntries(collection: Collection, depth: number | null = null) { - const selectedDepth = - depth || - collection.get('nested')?.get('depth') || - getPathDepth(collection.get('path', '') as string); - + async listAllEntries(collection: Collection) { + const depth = collectionDepth(collection); if (collection.get('folder') && this.implementation.allEntriesByFolder) { const extension = selectFolderEntryExtension(collection); return this.implementation - .allEntriesByFolder(collection.get('folder') as string, extension, selectedDepth) + .allEntriesByFolder(collection.get('folder') as string, extension, depth) .then(entries => this.processEntries(entries, collection)); } - const response = await this.listEntries(collection, selectedDepth); + const response = await this.listEntries(collection); const { entries } = response; let { cursor } = response; while (cursor && cursor.actions!.includes('next')) { @@ -526,39 +538,38 @@ export class Backend { async listAllMultipleEntires(collection: Collection) { const i18nStructure = collection.get('i18n_structure'); - const locales = collection.get('locales') as string[]; - const depth = i18nStructure === LOCALE_FOLDERS ? 2 : null; - const entries = await this.listAllEntries(collection, depth); + const locales = collection.get('locales') as List; + const entries = await this.listAllEntries(collection); let multiEntries; if (i18nStructure === LOCALE_FILE_EXTENSIONS) { multiEntries = entries .filter(entry => locales.some(l => entry.slug.endsWith(`.${l}`))) .map(entry => { - const locale = entry.slug.slice(-2); + const locale = entryLocale(collection, entry.slug); return { ...entry, slug: entry.slug.replace(`.${locale}`, ''), contentKey: entry.path.replace(`.${locale}`, ''), i18nStructure, - slugWithLocale: entry.slug, + locale, }; }); } else if (i18nStructure === LOCALE_FOLDERS) { multiEntries = entries .filter(entry => locales.some(l => entry.slug.startsWith(`${l}/`))) .map(entry => { - const locale = entry.slug.slice(0, 2); + const locale = entryLocale(collection, entry.slug); return { ...entry, slug: entry.slug.replace(`${locale}/`, ''), contentKey: entry.path.replace(`${locale}/`, ''), i18nStructure, - slugWithLocale: entry.slug, + locale, }; }); } - return this.combineMultipleContentEntries(multiEntries as EntryValue[]); + return this.mergeMultipleContentEntries(multiEntries as EntryValue[]); } async search(collections: Collection[], searchTerm: string) { @@ -762,25 +773,27 @@ export class Backend { const path = selectEntryPath(collection, slug) as string; const label = selectFileEntryLabel(collection, slug); const extension = selectFolderEntryExtension(collection); - const multiContent = collection.get('multi_content'); + const multiContent = hasMultiContent(collection); const i18nStructure = collection.get('i18n_structure'); - const locales = collection.get('locales') as string[]; + const locales = collection.get('locales')!.toJS() as string[]; let loadedEntries; if (multiContent && i18nStructure === LOCALE_FILE_EXTENSIONS) { loadedEntries = await Promise.all( - locales.map(l => + locales.map(locale => this.implementation - .getEntry(path.replace(extension, `${l}.${extension}`)) - .catch(() => undefined), + .getEntry(path.replace(extension, `${locale}.${extension}`)) + .then(entry => (entry.data ? entry : null)) + .catch(() => false), ), ); } else if (multiContent && i18nStructure === LOCALE_FOLDERS) { loadedEntries = await Promise.all( - locales.map(l => + locales.map(locale => this.implementation - .getEntry(path.replace(`/${slug}`, `/${l}/${slug}`)) - .catch(() => undefined), + .getEntry(path.replace(`/${slug}`, `/${locale}/${slug}`)) + .then(entry => (entry.data ? entry : null)) + .catch(() => null), ), ); } else { @@ -788,13 +801,18 @@ export class Backend { loadedEntries = [loadedEntry]; } + const filteredLoadedEntries = loadedEntries.filter(Boolean) as ImplementationEntry[]; const entries = await Promise.all( - loadedEntries.filter(Boolean).map(async (loadedEntry: any) => { + filteredLoadedEntries.map(async loadedEntry => { + const locale = entryLocale( + collection, + selectEntrySlug(collection, loadedEntry.file.path) as string, + ); let entry = createEntry(collection.get('name'), slug, loadedEntry.file.path, { raw: loadedEntry.data, label, i18nStructure, - slugWithLocale: selectEntrySlug(collection, loadedEntry.file.path), + locale, mediaFiles: [], meta: { path: prepareMetaPath(loadedEntry.file.path, collection) }, }); @@ -806,8 +824,8 @@ export class Backend { }), ); - if (collection.get('multi_content_diff_files')) { - return this.combineEntries(entries); + if (hasMultiContentDiffFiles(collection)) { + return this.mergeEntries(entries); } return entries[0]; @@ -890,7 +908,7 @@ export class Backend { ); data = loadedEntry.data; path = loadedEntry.file.path; - } else if (collection.get('multi_content_diff_files')) { + } else if (hasMultiContentDiffFiles(collection)) { data = await Promise.all( dataFiles.map(async file => { const data = await this.implementation.unpublishedEntryDataFile( @@ -918,6 +936,9 @@ export class Backend { if (Array.isArray(data)) { const multipleEntries = data.map(d => { + const i18nStructure = collection.get('i18n_structure'); + const locale = entryLocale(collection, selectEntrySlug(collection, d.path) as string); + return this.entryWithFormat(collection)( createEntry(collection.get('name'), slug, path, { raw: d.data, @@ -927,13 +948,13 @@ export class Backend { updatedOn: entryData.updatedAt, status: entryData.status, meta: { path: prepareMetaPath(path, collection) }, - i18nStructure: collection.get('i18n_structure'), - slugWithLocale: selectEntrySlug(collection, d.path), + i18nStructure, + locale, }), ); }); - entryWithFormat = this.combineEntries(multipleEntries); + entryWithFormat = this.mergeEntries(multipleEntries); } else { const entry = createEntry(collection.get('name'), slug, path, { raw: data, @@ -998,31 +1019,29 @@ export class Backend { return entry; } - combineMultipleContentEntries(entries: EntryValue[]) { - const groupEntries = groupBy(entries, 'contentKey'); + mergeMultipleContentEntries(entries: (EntryValue & MultiContentArgs)[]) { + const groupEntries = groupBy(entries, e => e.contentKey); return Object.keys(groupEntries).reduce((acc: EntryValue[], key: string) => { const entries = groupEntries[key]; - return [...acc, this.combineEntries(entries)]; + return [...acc, this.mergeEntries(entries)]; }, []); } - combineEntries(entries: EntryValue[]) { - const { i18nStructure, contentKey, slugWithLocale, ...entry } = entries[0]; + mergeEntries(entries: (EntryValue & MultiContentArgs)[]) { + const { i18nStructure, contentKey, ...entry } = entries[0]; const data: { [key: string]: any } = {}; let path = ''; - let locale; - entries.forEach((e: EntryValue) => { + + entries.forEach((e: EntryValue & MultiContentArgs) => { if (i18nStructure === LOCALE_FILE_EXTENSIONS) { - locale = e!.slugWithLocale!.slice(-2) as string; - !path && (path = e.path.replace(`.${locale}`, '')); - data[locale] = e.data; + !path && (path = e.path.replace(`.${e.locale}`, '')); + data[e.locale as string] = e.data; } else if (i18nStructure === LOCALE_FOLDERS) { - locale = e!.slugWithLocale!.slice(0, 2) as string; - !path && (path = e.path.replace(`${locale}/`, '')); - data[locale] = e.data; + !path && (path = e.path.replace(`${e.locale}/`, '')); + data[e.locale as string] = e.data; } }); - return { ...entry, path, raw: '', data }; + return { ...entry, path, raw: '', data, multiContent: true }; } /** @@ -1161,7 +1180,7 @@ export class Backend { } let entriesObj = [entryObj]; - if (collection.get('multi_content_diff_files')) { + if (hasMultiContentDiffFiles(collection)) { entriesObj = this.getMultipleEntries(collection, entryDraft, entryObj); } @@ -1294,7 +1313,7 @@ export class Backend { const config = state.config; const path = selectEntryPath(collection, slug) as string; const extension = selectFolderEntryExtension(collection) as string; - const locales = collection.get('locales') as string[]; + const locales = collection.get('locales')!.toJS() as string[]; if (!selectAllowDeletion(collection)) { throw new Error('Not allowed to delete entries in this collection'); @@ -1316,7 +1335,7 @@ export class Backend { const entry = selectEntry(state.entries, collection.get('name'), slug); await this.invokePreUnpublishEvent(entry); - if (collection.get('multi_content_diff_files')) { + if (hasMultiContentDiffFiles(collection)) { const i18nStructure = collection.get('i18n_structure') as string; if (i18nStructure === LOCALE_FILE_EXTENSIONS) { for (const l of locales) { diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js index ed2ee887f6c3..f44258d949de 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js @@ -70,12 +70,23 @@ const styleStrings = { const ControlContainer = styled.div` margin-top: 16px; + position: relative; &:first-of-type { margin-top: 36px; } `; +const HideContainer = styled.div` + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + background-color: #fff; + z-index: 1; +`; + const ControlErrorsList = styled.ul` list-style-type: none; font-size: 12px; @@ -228,7 +239,8 @@ class EditorControl extends React.Component { const childErrors = this.isAncestorOfFieldError(); const hasErrors = !!errors || childErrors; const multiContentWidgetId = field.get('multiContentId') === Symbol.for('multiContentId'); - const locales = this.props.collection.get('locales'); + const nonTranslatableField = field.get('translatable') === false; + const locales = collection.get('locales'); const label = locales && multiContentWidgetId ? ( {({ css, cx }) => ( + {nonTranslatableField && } {widget.globalStyles && } {errors && ( diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js index a746587f27d1..499fd040bf63 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import styled from '@emotion/styled'; import EditorControl from './EditorControl'; +import { hasMultiContent } from 'Reducers/collections'; const ControlPaneContainer = styled.div` max-width: 800px; @@ -18,18 +19,6 @@ export default class ControlPane extends React.Component { componentValidate = {}; - componentDidUpdate(prevProps) { - if ( - this.props.collection.get('multi_content') && - !prevProps.fieldsErrors.equals(this.props.fieldsErrors) && - this.props.defaultEditor - ) { - // show default locale fields on field error - const defaultLocale = this.props.collection.get('locales').first(); - this.handleLocaleChange(defaultLocale); - } - } - controlRef(field, wrappedControl) { if (!wrappedControl) return; const name = field.get('name'); @@ -38,11 +27,10 @@ export default class ControlPane extends React.Component { wrappedControl.innerWrappedControl?.validate || wrappedControl.validate; } - getFields = (defaultLocale = '') => { + getFields = () => { let fields = this.props.fields; - const selectedLocale = defaultLocale || this.state.selectedLocale; - if (this.props.collection.get('multi_content')) { - fields = fields.filter(f => f.get('name') === selectedLocale); + if (hasMultiContent(this.props.collection)) { + fields = fields.filter(f => f.get('name') === this.state.selectedLocale); } return fields; }; @@ -51,10 +39,14 @@ export default class ControlPane extends React.Component { this.setState({ selectedLocale: val }); }; - validate = () => { + defaultLocale = () => { const collection = this.props.collection; - const defaultLocale = collection.get('multi_content') && collection.get('locales').first(); - this.getFields(defaultLocale).forEach(field => { + const defaultLocale = hasMultiContent(collection) && collection.get('default_locale'); + return new Promise(resolve => this.setState({ selectedLocale: defaultLocale }, resolve)); + }; + + validate = async () => { + this.getFields().forEach(field => { if (field.get('widget') === 'hidden') return; this.componentValidate[field.get('name')](); }); diff --git a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js index b318c31ccea7..68e7a7021bb5 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js @@ -16,6 +16,7 @@ import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync'; import EditorControlPane from './EditorControlPane/EditorControlPane'; import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane'; import EditorToolbar from './EditorToolbar'; +import { hasMultiContent } from 'Reducers/collections'; const PREVIEW_VISIBLE = 'cms.preview-visible'; const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled'; @@ -132,14 +133,16 @@ class EditorInterface extends Component { this.setState({ showEventBlocker: false }); }; - handleOnPersist = (opts = {}) => { + handleOnPersist = async (opts = {}) => { const { createNew = false, duplicate = false } = opts; + hasMultiContent(this.props.collection) && (await this.handleRefDefaultLocale()); this.handleRefValidation(); this.props.onPersist({ createNew, duplicate }); }; - handleOnPublish = (opts = {}) => { + handleOnPublish = async (opts = {}) => { const { createNew = false, duplicate = false } = opts; + hasMultiContent(this.props.collection) && (await this.handleRefDefaultLocale()); this.handleRefValidation(); this.props.onPublish({ createNew, duplicate }); }; @@ -160,6 +163,10 @@ class EditorInterface extends Component { this.controlPaneRef.validate(); }; + handleRefDefaultLocale = () => { + this.controlPaneRef.defaultLocale(); + }; + render() { const { collection, @@ -190,11 +197,11 @@ class EditorInterface extends Component { deployPreview, draftKey, editorBackLink, + clearFieldErrors, } = this.props; const { previewVisible, scrollSyncEnabled, showEventBlocker } = this.state; const collectionPreviewEnabled = collection.getIn(['editor', 'preview'], true); - const multiContent = collection.get('multi_content'); const locales = this.props.collection.get('locales'); const editorProps = { collection, @@ -204,6 +211,7 @@ class EditorInterface extends Component { fieldsErrors, onChange, onValidate, + clearFieldErrors, }; const editor = ( @@ -320,7 +328,7 @@ class EditorInterface extends Component { /> )} - {multiContent ? ( + {hasMultiContent(collection) ? ( editorWithEditor ) : collectionPreviewEnabled && this.state.previewVisible ? ( editorWithPreview diff --git a/packages/netlify-cms-core/src/constants/backendTypes.js b/packages/netlify-cms-core/src/constants/backendTypes.js new file mode 100644 index 000000000000..6dc40eba3b14 --- /dev/null +++ b/packages/netlify-cms-core/src/constants/backendTypes.js @@ -0,0 +1 @@ +export const TEST = 'test-repo'; diff --git a/packages/netlify-cms-core/src/constants/configSchema.js b/packages/netlify-cms-core/src/constants/configSchema.js index 8c6d573982af..d3f857ba895f 100644 --- a/packages/netlify-cms-core/src/constants/configSchema.js +++ b/packages/netlify-cms-core/src/constants/configSchema.js @@ -233,21 +233,16 @@ const getConfigSchema = () => ({ }, required: ['name', 'label'], oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }], - allOf: [ - { - if: { required: ['extension'] }, - then: { - // Cannot infer format from extension. - if: { - properties: { - extension: { enum: Object.keys(extensionFormatters) }, - }, - }, - else: { required: ['format'] }, + if: { required: ['extension'] }, + then: { + // Cannot infer format from extension. + if: { + properties: { + extension: { enum: Object.keys(extensionFormatters) }, }, }, - { if: { required: ['files'] }, then: { not: { required: ['i18n_structure'] } } }, - ], + else: { required: ['format'] }, + }, dependencies: { frontmatter_delimiter: { properties: { diff --git a/packages/netlify-cms-core/src/constants/multiContentTypes.js b/packages/netlify-cms-core/src/constants/multiContentTypes.js index b4f6e49ec250..2a760828f427 100644 --- a/packages/netlify-cms-core/src/constants/multiContentTypes.js +++ b/packages/netlify-cms-core/src/constants/multiContentTypes.js @@ -4,125 +4,322 @@ export const LOCALE_FOLDERS = 'locale_folders'; export const DIFF_FILE_TYPES = [LOCALE_FILE_EXTENSIONS, LOCALE_FOLDERS]; export const locales = [ + 'Cy-az-AZ', + 'Cy-sr-SP', + 'Cy-uz-UZ', + 'Lt-az-AZ', + 'Lt-sr-SP', + 'Lt-uz-UZ', 'aa', + 'ab', + 'ae', 'af', + 'af-ZA', 'ak', 'am', + 'an', 'ar', + 'ar-AE', + 'ar-BH', + 'ar-DZ', + 'ar-EG', + 'ar-IQ', + 'ar-JO', + 'ar-KW', + 'ar-LB', + 'ar-LY', + 'ar-MA', + 'ar-OM', + 'ar-QA', + 'ar-SA', + 'ar-SY', + 'ar-TN', + 'ar-YE', 'as', + 'av', + 'ay', + 'az', 'ba', 'be', + 'be-BY', 'bg', + 'bg-BG', + 'bh', + 'bi', + 'bm', + 'bn', 'bo', 'br', + 'bs', 'ca', + 'ca-ES', 'ce', + 'ch', 'co', + 'cr', 'cs', + 'cs-CZ', 'cu', + 'cv', 'cy', 'da', + 'da-DK', 'de', + 'de-AT', + 'de-CH', + 'de-DE', + 'de-LI', + 'de-LU', + 'div-MV', 'dv', 'dz', 'ee', + 'el', + 'el-GR', 'en', + 'en-AU', + 'en-BZ', + 'en-CA', + 'en-CB', + 'en-GB', + 'en-IE', + 'en-JM', + 'en-NZ', + 'en-PH', + 'en-TT', + 'en-US', + 'en-ZA', + 'en-ZW', 'eo', 'es', + 'es-AR', + 'es-BO', + 'es-CL', + 'es-CO', + 'es-CR', + 'es-DO', + 'es-EC', + 'es-ES', + 'es-GT', + 'es-HN', + 'es-MX', + 'es-NI', + 'es-PA', + 'es-PE', + 'es-PR', + 'es-PY', + 'es-SV', + 'es-UY', + 'es-VE', 'et', + 'et-EE', 'eu', + 'eu-ES', 'fa', + 'fa-IR', 'ff', 'fi', + 'fi-FI', + 'fj', 'fo', + 'fo-FO', 'fr', + 'fr-BE', + 'fr-CA', + 'fr-CH', + 'fr-FR', + 'fr-LU', + 'fr-MC', + 'fy', 'ga', 'gd', 'gl', + 'gl-ES', 'gn', 'gu', + 'gu-IN', 'gv', + 'ha', 'he', + 'he-IL', 'hi', + 'hi-IN', + 'ho', 'hr', + 'hr-HR', + 'ht', 'hu', + 'hu-HU', 'hy', + 'hy-AM', + 'hz', + 'ia', 'id', + 'id-ID', + 'ie', 'ig', + 'ii', + 'ik', + 'io', 'is', + 'is-IS', 'it', + 'it-CH', + 'it-IT', + 'iu', 'ja', + 'ja-JP', 'jv', 'ka', + 'ka-GE', + 'kg', 'ki', + 'kj', 'kk', + 'kk-KZ', 'kl', + 'km', 'kn', + 'kn-IN', 'ko', + 'ko-KR', + 'kr', 'ks', 'ku', + 'kv', 'kw', 'ky', + 'ky-KZ', + 'la', 'lb', 'lg', + 'li', 'ln', 'lo', 'lt', + 'lt-LT', 'lu', 'lv', + 'lv-LV', 'mg', + 'mh', 'mi', 'mk', + 'mk-MK', 'ml', + 'mn', + 'mn-MN', 'mr', + 'mr-IN', 'ms', + 'ms-BN', + 'ms-MY', 'mt', 'my', + 'na', 'nb', + 'nb-NO', 'nd', 'ne', + 'ng', 'nl', + 'nl-BE', + 'nl-NL', + 'nn', + 'nn-NO', + 'no', 'nr', + 'nv', + 'ny', + 'oc', + 'oj', 'om', + 'or', 'os', 'pa', + 'pa-IN', + 'pi', 'pl', + 'pl-PL', 'ps', 'pt', + 'pt-BR', + 'pt-PT', 'qu', 'rm', 'rn', 'ro', + 'ro-RO', 'ru', + 'ru-RU', 'rw', 'sa', + 'sa-IN', + 'sc', 'sd', + 'se', 'sg', 'si', 'sk', + 'sk-SK', 'sl', + 'sl-SI', + 'sm', 'sn', 'so', 'sq', + 'sq-AL', + 'sr', 'ss', + 'st', + 'su', 'sv', + 'sv-FI', + 'sv-SE', + 'sw', + 'sw-KE', 'ta', + 'ta-IN', 'te', + 'te-IN', + 'tg', 'th', + 'th-TH', 'ti', 'tk', + 'tl', + 'tn', + 'to', 'tr', + 'tr-TR', 'ts', 'tt', + 'tt-RU', + 'tw', + 'ty', 'ug', 'uk', + 'uk-UA', 'ur', + 'ur-PK', 'uz', 've', 'vi', + 'vi-VN', 'vo', + 'wa', 'wo', 'xh', + 'yi', 'yo', + 'za', + 'zh', + 'zh-CHS', + 'zh-CHT', + 'zh-CN', + 'zh-HK', + 'zh-MO', + 'zh-SG', + 'zh-TW', 'zu', ]; diff --git a/packages/netlify-cms-core/src/lib/serializeEntryValues.js b/packages/netlify-cms-core/src/lib/serializeEntryValues.js index 9f13c46de1c1..3276d4780eb0 100644 --- a/packages/netlify-cms-core/src/lib/serializeEntryValues.js +++ b/packages/netlify-cms-core/src/lib/serializeEntryValues.js @@ -1,6 +1,7 @@ import { isNil } from 'lodash'; import { Map, List } from 'immutable'; import { getWidgetValueSerializer } from './registry'; +import { duplicateFields, hasMultiContent } from '../reducers/collections'; /** * Methods for serializing/deserializing entry field values. Most widgets don't @@ -65,8 +66,10 @@ const runSerializer = (values, fields, method) => { return serializedData; }; -export const serializeValues = (values, fields) => { - return runSerializer(values, fields, 'serialize'); +export const serializeValues = (collection, values, fields) => { + let serializedValues = runSerializer(values, fields, 'serialize'); + hasMultiContent(collection) && (serializedValues = duplicateFields(collection, serializedValues)); + return serializedValues; }; export const deserializeValues = (values, fields) => { diff --git a/packages/netlify-cms-core/src/reducers/__tests__/collections.spec.js b/packages/netlify-cms-core/src/reducers/__tests__/collections.spec.js index eb232d5b2492..779c4b2da324 100644 --- a/packages/netlify-cms-core/src/reducers/__tests__/collections.spec.js +++ b/packages/netlify-cms-core/src/reducers/__tests__/collections.spec.js @@ -10,6 +10,7 @@ import collections, { getFieldsNames, selectField, updateFieldByKey, + duplicateFields, } from '../collections'; import { FILES, FOLDER } from 'Constants/collectionTypes'; @@ -548,4 +549,62 @@ describe('collections', () => { ); }); }); + + describe('duplicateFields', () => { + it('should duplicate field values across locales', () => { + const collection = fromJS({ + locales: ['en', 'fr'], + default_locale: 'en', + fields: [ + { + name: 'en', + fields: [ + { name: 'title' }, + { name: 'image', duplicate: true }, + { name: 'fieldList', field: { name: 'image', duplicate: true } }, + { + name: 'fieldsList', + fields: [{ name: 'title' }, { name: 'image', duplicate: true }], + }, + ], + }, + { + name: 'fr', + fields: [ + { name: 'title' }, + { name: 'image' }, + { name: 'fieldList', field: { name: 'image' } }, + { + name: 'fieldsList', + fields: [{ name: 'title' }, { name: 'image' }], + }, + ], + }, + ], + }); + const values = fromJS({ + en: { + title: 'title', + image: 'image.png', + fieldList: ['image2.png'], + fieldsList: [{ title: 'title', image: 'image3.png' }, { image: 'image4.png' }], + }, + }); + expect(duplicateFields(collection, values)).toEqual( + fromJS({ + en: { + title: 'title', + image: 'image.png', + fieldList: ['image2.png'], + fieldsList: [{ title: 'title', image: 'image3.png' }, { image: 'image4.png' }], + }, + fr: { + image: 'image.png', + fieldList: ['image2.png'], + fieldsList: [{ image: 'image3.png' }, { image: 'image4.png' }], + }, + }), + ); + }); + }); }); diff --git a/packages/netlify-cms-core/src/reducers/collections.ts b/packages/netlify-cms-core/src/reducers/collections.ts index 7b717e250009..e2d189ff79a3 100644 --- a/packages/netlify-cms-core/src/reducers/collections.ts +++ b/packages/netlify-cms-core/src/reducers/collections.ts @@ -1,14 +1,16 @@ -import { List, Set } from 'immutable'; -import { get, escapeRegExp } from 'lodash'; +import { List, Set, Map, fromJS } from 'immutable'; +import { get, escapeRegExp, set } from 'lodash'; import consoleError from '../lib/consoleError'; import { CONFIG_SUCCESS } from '../actions/config'; import { FILES, FOLDER } from '../constants/collectionTypes'; import { INFERABLE_FIELDS, IDENTIFIER_FIELDS, SORTABLE_FIELDS } from '../constants/fieldInference'; +import { DIFF_FILE_TYPES } from '../constants/multiContentTypes'; import { formatExtensions } from '../formats/formats'; import { CollectionsAction, Collection, CollectionFiles, + EntryFields, EntryField, State, EntryMap, @@ -458,4 +460,80 @@ export const selectHasMetaPath = (collection: Collection) => { ); }; +export const hasMultiContent = (collection: Collection) => { + return collection.get('locales'); +}; + +export const hasMultiContentDiffFiles = (collection: Collection) => { + return ( + hasMultiContent(collection) && + DIFF_FILE_TYPES.includes(collection.get('i18n_structure') as string) + ); +}; + +export const selectDuplicateFieldPaths = ( + values: any, + fields: EntryFields, + path = '', +): string[] => { + return fields.reduce((reduction, item) => { + const acc = reduction as string[]; + const field = item as EntryField; + const fieldName = field.get('name'); + const value = Map.isMap(values) ? values.get(fieldName) : values; + const nestedFields = field.get('fields') || field.get('field'); + const fieldPath = path ? `${path}${Map.isMap(values) ? `.${fieldName}` : ''}` : fieldName; + const duplicateField = field.get('duplicate'); + + if (nestedFields && List.isList(value)) { + return acc.concat( + ...value.map((val: any, index: number) => + selectDuplicateFieldPaths( + val, + List.isList(nestedFields) + ? (nestedFields as EntryFields) + : (List([nestedFields]) as EntryFields), + `${fieldPath}.${index}`, + ), + ), + ); + } + + if (nestedFields && Map.isMap(value)) { + return acc.concat( + ...selectDuplicateFieldPaths( + value, + List.isList(nestedFields) + ? (nestedFields as EntryFields) + : (List([nestedFields]) as EntryFields), + `${fieldPath}`, + ), + ); + } + + if (duplicateField) { + return acc.concat(fieldPath); + } + + return acc; + }, [] as string[]); +}; + +export const duplicateFields = (collection: Collection, values: any) => { + const data = values.toJS(); + const locales = collection.get('locales') as List; + const defaultLocale = collection.get('default_locale') as string; + const paths = selectDuplicateFieldPaths(values, List([collection.get('fields').first()])); + paths.forEach((path: string) => { + const duplicateValue = get(data, path); + if (duplicateValue) { + locales.shift().forEach(l => { + set(data, `${l}${path.substring(defaultLocale.length)}`, duplicateValue); + }); + } + }); + + return fromJS(data); +}; + export default collections; diff --git a/packages/netlify-cms-core/src/types/redux.ts b/packages/netlify-cms-core/src/types/redux.ts index 20875a3c1125..9caaf6f702b4 100644 --- a/packages/netlify-cms-core/src/types/redux.ts +++ b/packages/netlify-cms-core/src/types/redux.ts @@ -122,6 +122,8 @@ export type EntryField = StaticallyTypedRecord<{ public_folder?: string; comment?: string; meta?: boolean; + translatable?: boolean; + duplicate?: boolean; }>; export type EntryFields = List; @@ -186,10 +188,8 @@ type CollectionObject = { view_filters: List>; nested?: Nested; meta?: Meta; - locales?: string[]; + locales?: List; default_locale?: string; - multi_content?: boolean; - multi_content_diff_files?: boolean; i18n_structure?: string; }; diff --git a/packages/netlify-cms-core/src/valueObjects/Entry.ts b/packages/netlify-cms-core/src/valueObjects/Entry.ts index 32f83ee8b9de..2c9a7db25405 100644 --- a/packages/netlify-cms-core/src/valueObjects/Entry.ts +++ b/packages/netlify-cms-core/src/valueObjects/Entry.ts @@ -13,9 +13,12 @@ interface Options { updatedOn?: string; status?: string; meta?: { path?: string }; +} + +export interface MultiContentArgs { i18nStructure?: string; contentKey?: string; - slugWithLocale?: string; + locale?: string; } export interface EntryValue { @@ -33,12 +36,15 @@ export interface EntryValue { updatedOn: string; status?: string; meta: { path?: string }; - i18nStructure?: string; - contentKey?: string; - slugWithLocale?: string; + multiContent?: boolean; } -export function createEntry(collection: string, slug = '', path = '', options: Options = {}) { +export function createEntry( + collection: string, + slug = '', + path = '', + options: Options & MultiContentArgs = {}, +) { const returnObj: EntryValue = { collection, slug, @@ -59,8 +65,8 @@ export function createEntry(collection: string, slug = '', path = '', options: O ...(options.i18nStructure && { i18nStructure: options.i18nStructure, }), - ...(options.slugWithLocale && { - slugWithLocale: options.slugWithLocale, + ...(options.locale && { + locale: options.locale, }), }; From 24ec06b0cc5fdf43f7f908a10ce93acd489db030 Mon Sep 17 00:00:00 2001 From: barthc Date: Thu, 23 Jul 2020 23:04:58 +0100 Subject: [PATCH 21/23] fix: tests --- .../netlify-cms-core/src/__tests__/backend.spec.js | 12 ++++++++---- .../src/actions/__tests__/config.spec.js | 1 + packages/netlify-cms-core/src/backend.ts | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/netlify-cms-core/src/__tests__/backend.spec.js b/packages/netlify-cms-core/src/__tests__/backend.spec.js index 3fb6bac1dc37..5196e686fa12 100644 --- a/packages/netlify-cms-core/src/__tests__/backend.spec.js +++ b/packages/netlify-cms-core/src/__tests__/backend.spec.js @@ -954,14 +954,14 @@ describe('Backend', () => { path: 'posts/post.en.md', data: { title: 'Title en', content: 'Content en' }, i18nStructure: 'locale_file_extensions', - slugWithLocale: 'post.en', + locale: 'en', contentKey: 'posts/post', }, { path: 'posts/post.fr.md', data: { title: 'Title fr', content: 'Content fr' }, i18nStructure: 'locale_file_extensions', - slugWithLocale: 'post.fr', + locale: 'fr', contentKey: 'posts/post', }, ]; @@ -969,6 +969,7 @@ describe('Backend', () => { expect(backend.mergeMultipleContentEntries(entries)).toEqual([ { path: 'posts/post.md', + multiContent: true, raw: '', data: { en: { title: 'Title en', content: 'Content en' }, @@ -984,14 +985,14 @@ describe('Backend', () => { path: 'posts/en/post.md', data: { title: 'Title en', content: 'Content en' }, i18nStructure: 'locale_folders', - slugWithLocale: 'en/post.md', + locale: 'en', contentKey: 'posts/post', }, { path: 'posts/fr/post.md', data: { title: 'Title fr', content: 'Content fr' }, i18nStructure: 'locale_folders', - slugWithLocale: 'fr/post.md', + locale: 'fr', contentKey: 'posts/post', }, ]; @@ -999,6 +1000,7 @@ describe('Backend', () => { expect(backend.mergeMultipleContentEntries(entries)).toEqual([ { path: 'posts/post.md', + multiContent: true, raw: '', data: { en: { title: 'Title en', content: 'Content en' }, @@ -1043,6 +1045,7 @@ describe('Backend', () => { { slug: 'post', path: 'posts/post.md', + multiContent: true, raw: '', data: { en: { title: 'Title en', content: 'Content en' }, @@ -1073,6 +1076,7 @@ describe('Backend', () => { { slug: 'post', path: 'posts/post.md', + multiContent: true, raw: '', data: { en: { title: 'Title en', content: 'Content en' }, 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 9af4573f0c67..eb316b886b00 100644 --- a/packages/netlify-cms-core/src/actions/__tests__/config.spec.js +++ b/packages/netlify-cms-core/src/actions/__tests__/config.spec.js @@ -610,6 +610,7 @@ describe('config', () => { multiContentId: Symbol.for('multiContentId'), fields: [ { name: 'title', widget: 'string', translatable: true }, + { name: 'date', widget: 'date', translatable: false }, { name: 'content', widget: 'markdown', translatable: true }, ], }, diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index cc3987b686d8..665b34c15251 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -1028,7 +1028,7 @@ export class Backend { } mergeEntries(entries: (EntryValue & MultiContentArgs)[]) { - const { i18nStructure, contentKey, ...entry } = entries[0]; + const { i18nStructure, contentKey, locale, ...entry } = entries[0]; const data: { [key: string]: any } = {}; let path = ''; From 82a30fea32d767e3c6c83fa93bb3026bd9780aad Mon Sep 17 00:00:00 2001 From: barthc Date: Fri, 24 Jul 2020 00:21:50 +0100 Subject: [PATCH 22/23] fix: tests --- packages/netlify-cms-core/src/backend.ts | 2 +- .../src/lib/__tests__/serializeEntryValues.spec.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index 665b34c15251..419f6067e923 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -775,7 +775,7 @@ export class Backend { const extension = selectFolderEntryExtension(collection); const multiContent = hasMultiContent(collection); const i18nStructure = collection.get('i18n_structure'); - const locales = collection.get('locales')!.toJS() as string[]; + const locales = collection?.get('locales')?.toJS() as string[]; let loadedEntries; if (multiContent && i18nStructure === LOCALE_FILE_EXTENSIONS) { diff --git a/packages/netlify-cms-core/src/lib/__tests__/serializeEntryValues.spec.js b/packages/netlify-cms-core/src/lib/__tests__/serializeEntryValues.spec.js index 85c54592674c..d10ed0eb37c3 100644 --- a/packages/netlify-cms-core/src/lib/__tests__/serializeEntryValues.spec.js +++ b/packages/netlify-cms-core/src/lib/__tests__/serializeEntryValues.spec.js @@ -3,10 +3,11 @@ import { fromJS } from 'immutable'; const values = fromJS({ title: 'New Post', unknown: 'Unknown Field' }); const fields = fromJS([{ name: 'title', widget: 'string' }]); +const collection = fromJS({ name: 'collection' }); describe('serializeValues', () => { it('should retain unknown fields', () => { - expect(serializeValues(values, fields)).toEqual( + expect(serializeValues(collection, values, fields)).toEqual( fromJS({ title: 'New Post', unknown: 'Unknown Field' }), ); }); From 0f230d458c3f3de60da693a558ac877b005d5e17 Mon Sep 17 00:00:00 2001 From: barthc Date: Sat, 1 Aug 2020 16:59:53 +0100 Subject: [PATCH 23/23] chore: clean up --- packages/netlify-cms-core/src/actions/editorialWorkflow.ts | 2 +- packages/netlify-cms-core/src/backend.ts | 2 +- .../Editor/EditorControlPane/EditorControlPane.js | 6 +++--- .../src/components/Editor/EditorInterface.js | 4 ++-- packages/netlify-cms-core/src/constants/configSchema.js | 1 + 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/netlify-cms-core/src/actions/editorialWorkflow.ts b/packages/netlify-cms-core/src/actions/editorialWorkflow.ts index c5aa333f3a90..bee6fbf1e7de 100644 --- a/packages/netlify-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/netlify-cms-core/src/actions/editorialWorkflow.ts @@ -299,7 +299,7 @@ export function loadUnpublishedEntry(collection: Collection, slug: string) { `pages.${collection.get('name')}.ids`, false, ); - !publishedEntries && (await dispatch(loadEntry(collection, slug))); + !publishedEntries && (await dispatch(loadEntries(collection))); const publishedEntry = selectEntry(getState(), collection.get('name'), slug); publishedEntry && entry.isModification === false && diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index 419f6067e923..f581cfa38443 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -784,7 +784,7 @@ export class Backend { this.implementation .getEntry(path.replace(extension, `${locale}.${extension}`)) .then(entry => (entry.data ? entry : null)) - .catch(() => false), + .catch(() => null), ), ); } else if (multiContent && i18nStructure === LOCALE_FOLDERS) { diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js index 499fd040bf63..8759e038359d 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js @@ -40,9 +40,9 @@ export default class ControlPane extends React.Component { }; defaultLocale = () => { - const collection = this.props.collection; - const defaultLocale = hasMultiContent(collection) && collection.get('default_locale'); - return new Promise(resolve => this.setState({ selectedLocale: defaultLocale }, resolve)); + return new Promise(resolve => + this.setState({ selectedLocale: this.props.collection.get('default_locale') }, resolve), + ); }; validate = async () => { diff --git a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js index 68e7a7021bb5..fbf8887a1881 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js @@ -220,14 +220,14 @@ class EditorInterface extends Component { {...editorProps} ref={c => (this.controlPaneRef = c)} defaultEditor - locale={locales && locales.first()} + locale={locales && locales.get(0)} /> ); const editor2 = ( - + ); diff --git a/packages/netlify-cms-core/src/constants/configSchema.js b/packages/netlify-cms-core/src/constants/configSchema.js index d3f857ba895f..6059093ff614 100644 --- a/packages/netlify-cms-core/src/constants/configSchema.js +++ b/packages/netlify-cms-core/src/constants/configSchema.js @@ -27,6 +27,7 @@ const fieldsConfig = () => ({ widget: { type: 'string' }, required: { type: 'boolean' }, translatable: { type: 'boolean' }, + duplicate: { type: 'boolean' }, hint: { type: 'string' }, pattern: { type: 'array',