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 9e8322766a28..653ef95a41d1 100644 --- a/packages/netlify-cms-core/src/__tests__/backend.spec.js +++ b/packages/netlify-cms-core/src/__tests__/backend.spec.js @@ -790,7 +790,7 @@ describe('Backend', () => { }, ], }); - }); + }); }); describe('getMultipleEntries', () => { 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 a70ac72e8560..fdde8ed80ce7 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -54,17 +54,13 @@ import { } from './types/redux'; import AssetProxy from './valueObjects/AssetProxy'; import { FOLDER, FILES } from './constants/collectionTypes'; -<<<<<<< HEAD import { selectCustomPath } from './reducers/entryDraft'; import { UnpublishedEntry } from 'netlify-cms-lib-util/src/implementation'; -import { SAME_FOLDER, DIFF_FOLDER, DIFF_FILE_TYPES } from 'Constants/multiContentTypes'; -======= import { LOCALE_FILE_EXTENSIONS, LOCALE_FOLDERS, DIFF_FILE_TYPES, } from 'Constants/multiContentTypes'; ->>>>>>> 99de665d... fix: feedback changes const { extractTemplateVars, dateParsers } = stringTemplate; @@ -425,7 +421,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); @@ -480,6 +476,7 @@ export class Backend { }; }); } + return { entries: this.combineMultipleContentEntries(multiEntries) }; } @@ -683,35 +680,19 @@ export class Backend { raw: loadedEntry.data, label, i18nStructure, + slugWithLocale: selectEntrySlug(collection, loadedEntry.file.path), mediaFiles: [], meta: { path: prepareMetaPath(loadedEntry.file.path, collection) }, }); -<<<<<<< HEAD entry = this.entryWithFormat(collection)(entry); entry = await this.processEntry(state, collection, entry); -======= - const entryWithFormat = this.entryWithFormat(collection)(entry); - if (!mediaFiles) { - const mediaFolders = selectMediaFolders(state, collection, fromJS(entryWithFormat)); - if (mediaFolders.length > 0 && !integration) { - mediaFiles = []; - for (const folder of mediaFolders) { - mediaFiles = [...entry.mediaFiles, ...(await this.implementation.getMedia(folder))]; - } - } else { - mediaFiles = state.mediaLibrary.get('files') || []; - } - } - entry.mediaFiles = mediaFiles; - entry.slugWithLocale = selectEntrySlug(collection, entry.path); ->>>>>>> 99de665d... fix: feedback changes return entry; }), ); - if (multiContent && DIFF_FILE_TYPES.includes(i18nStructure)) { + if (collection.get('multi_content_diff_files')) { return this.combineEntries(entries); } @@ -762,20 +743,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( @@ -788,32 +802,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; } @@ -978,8 +998,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); @@ -1034,7 +1052,7 @@ export class Backend { } let entriesObj = [entryObj]; - if (MultiContentDiffFiles) { + if (collection.get('multi_content_diff_files')) { entriesObj = this.getMultipleEntries(collection, entryDraft, entryObj); } @@ -1161,8 +1179,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)) { @@ -1185,7 +1201,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;