Feat: multi content authoring (#4139)

This commit is contained in:
Erez Rokah
2020-09-20 10:30:46 -07:00
committed by GitHub
parent 7968e01e29
commit cb2ad687ee
65 changed files with 4331 additions and 1521 deletions

View File

@ -533,6 +533,182 @@ describe('config', () => {
],
});
});
describe('i18n', () => {
it('should set root i18n on collection when collection i18n is set to true', () => {
expect(
applyDefaults(
fromJS({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [
{ folder: 'foo', i18n: true, fields: [{ name: 'title', widget: 'string' }] },
],
}),
)
.getIn(['collections', 0, 'i18n'])
.toJS(),
).toEqual({ structure: 'multiple_folders', locales: ['en', 'de'], default_locale: 'en' });
});
it('should not set root i18n on collection when collection i18n is not set', () => {
expect(
applyDefaults(
fromJS({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [{ folder: 'foo', fields: [{ name: 'title', widget: 'string' }] }],
}),
).getIn(['collections', 0, 'i18n']),
).toBeUndefined();
});
it('should not set root i18n on collection when collection i18n is set to false', () => {
expect(
applyDefaults(
fromJS({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [
{ folder: 'foo', i18n: false, fields: [{ name: 'title', widget: 'string' }] },
],
}),
).getIn(['collections', 0, 'i18n']),
).toBeUndefined();
});
it('should merge root i18n on collection when collection i18n is set to an object', () => {
expect(
applyDefaults(
fromJS({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
default_locale: 'en',
},
collections: [
{
folder: 'foo',
i18n: { locales: ['en', 'fr'], default_locale: 'fr' },
fields: [{ name: 'title', widget: 'string' }],
},
],
}),
)
.getIn(['collections', 0, 'i18n'])
.toJS(),
).toEqual({ structure: 'multiple_folders', locales: ['en', 'fr'], default_locale: 'fr' });
});
it('should throw when i18n is set on files collection', () => {
expect(() =>
applyDefaults(
fromJS({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [
{
files: [
{ name: 'file', file: 'file', fields: [{ name: 'title', widget: 'string' }] },
],
i18n: true,
},
],
}),
),
).toThrow('i18n configuration is not supported for files collection');
});
it('should set i18n value to translate on field when i18n=true for field', () => {
expect(
applyDefaults(
fromJS({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [
{
folder: 'foo',
i18n: true,
fields: [{ name: 'title', widget: 'string', i18n: true }],
},
],
}),
).getIn(['collections', 0, 'fields', 0, 'i18n']),
).toEqual('translate');
});
it('should set i18n value to none on field when i18n=false for field', () => {
expect(
applyDefaults(
fromJS({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [
{
folder: 'foo',
i18n: true,
fields: [{ name: 'title', widget: 'string', i18n: false }],
},
],
}),
).getIn(['collections', 0, 'fields', 0, 'i18n']),
).toEqual('none');
});
it('should throw is default locale is missing from root i18n config', () => {
expect(() =>
applyDefaults(
fromJS({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
default_locale: 'fr',
},
collections: [
{
folder: 'foo',
fields: [{ name: 'title', widget: 'string' }],
},
],
}),
),
).toThrow("i18n locales 'en, de' are missing the default locale fr");
});
it('should throw is default locale is missing from collection i18n config', () => {
expect(() =>
applyDefaults(
fromJS({
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de'],
},
collections: [
{
folder: 'foo',
i18n: {
default_locale: 'fr',
},
fields: [{ name: 'title', widget: 'string' }],
},
],
}),
),
).toThrow("i18n locales 'en, de' are missing the default locale fr");
});
});
});
describe('detectProxyServer', () => {

View File

@ -6,6 +6,7 @@ import * as publishModes from 'Constants/publishModes';
import { validateConfig } from 'Constants/configSchema';
import { selectDefaultSortableFields, traverseFields } from '../reducers/collections';
import { resolveBackend } from 'coreSrc/backend';
import { I18N, I18N_FIELD } from '../lib/i18n';
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
export const CONFIG_SUCCESS = 'CONFIG_SUCCESS';
@ -58,6 +59,59 @@ const setSnakeCaseConfig = field => {
return field;
};
const setI18nField = field => {
if (field.get(I18N) === true) {
field = field.set(I18N, I18N_FIELD.TRANSLATE);
} else if (field.get(I18N) === false || !field.has(I18N)) {
field = field.set(I18N, I18N_FIELD.NONE);
}
return field;
};
const setI18nDefaults = (i18n, collection) => {
if (i18n && collection.has(I18N)) {
const collectionI18n = collection.get(I18N);
if (collectionI18n === true) {
collection = collection.set(I18N, i18n);
} else if (collectionI18n === false) {
collection = collection.delete(I18N);
} else {
const locales = collectionI18n.get('locales', i18n.get('locales'));
const defaultLocale = collectionI18n.get(
'default_locale',
collectionI18n.has('locales') ? locales.first() : i18n.get('default_locale'),
);
collection = collection.set(I18N, i18n.merge(collectionI18n));
collection = collection.setIn([I18N, 'locales'], locales);
collection = collection.setIn([I18N, 'default_locale'], defaultLocale);
throwOnMissingDefaultLocale(collection.get(I18N));
}
if (collectionI18n !== false) {
// set default values for i18n fields
collection = collection.set('fields', traverseFields(collection.get('fields'), setI18nField));
}
} else {
collection = collection.delete(I18N);
collection = collection.set(
'fields',
traverseFields(collection.get('fields'), field => field.delete(I18N)),
);
}
return collection;
};
const throwOnMissingDefaultLocale = i18n => {
if (i18n && !i18n.get('locales').includes(i18n.get('default_locale'))) {
throw new Error(
`i18n locales '${i18n.get('locales').join(', ')}' are missing the default locale ${i18n.get(
'default_locale',
)}`,
);
}
};
const defaults = {
publish_mode: publishModes.SIMPLE,
};
@ -132,6 +186,10 @@ export function applyDefaults(config) {
map.setIn(['slug', 'sanitize_replacement'], '-');
}
let i18n = config.get(I18N);
i18n = i18n?.set('default_locale', i18n.get('default_locale', i18n.get('locales').first()));
throwOnMissingDefaultLocale(i18n);
// Strip leading slash from collection folders and files
map.set(
'collections',
@ -167,10 +225,15 @@ export function applyDefaults(config) {
} else {
collection = collection.set('meta', Map());
}
collection = setI18nDefaults(i18n, collection);
}
const files = collection.get('files');
if (files) {
if (i18n && collection.has(I18N)) {
throw new Error('i18n configuration is not supported for files collection');
}
collection = collection.delete('nested');
collection = collection.delete('meta');
collection = collection.set(

View File

@ -4,7 +4,6 @@ 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 { serializeValues } from '../lib/serializeEntryValues';
import { currentBackend, slugFromCustomPath } from '../backend';
import {
selectPublishedSlugs,
@ -13,7 +12,6 @@ import {
selectUnpublishedEntry,
} from '../reducers';
import { selectEditingDraft } from '../reducers/entries';
import { selectFields } from '../reducers/collections';
import { EDITORIAL_WORKFLOW, status, Status } from '../constants/publishModes';
import { EDITORIAL_WORKFLOW_ERROR } from 'netlify-cms-lib-util';
import {
@ -22,11 +20,11 @@ import {
getMediaAssets,
createDraftFromEntry,
loadEntries,
getSerializedEntry,
} from './entries';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import { addAssets } from './media';
import { loadMedia } from './mediaLibrary';
import ValidationErrorTypes from '../constants/validationErrorTypes';
import { Collection, EntryMap, State, Collections, EntryDraft, MediaFile } from '../types/redux';
import { AnyAction } from 'redux';
@ -382,13 +380,7 @@ export function persistUnpublishedEntry(collection: Collection, existingUnpublis
entry,
});
/**
* Serialize the values of any fields with registered serializers, and
* update the entry and entryDraft with the serialized values.
*/
const fields = selectFields(collection, entry.get('slug'));
const serializedData = serializeValues(entry.get('data'), fields);
const serializedEntry = entry.set('data', serializedData);
const serializedEntry = getSerializedEntry(collection, entry);
const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
dispatch(unpublishedEntryPersisting(collection, serializedEntry, transactionID));

View File

@ -20,6 +20,7 @@ import {
EntryField,
SortDirection,
ViewFilter,
Entry,
} from '../types/redux';
import { ThunkDispatch } from 'redux-thunk';
@ -30,6 +31,7 @@ import { selectIsFetching, selectEntriesSortFields, selectEntryByPath } from '..
import { selectCustomPath } from '../reducers/entryDraft';
import { navigateToEntry } from '../routing/history';
import { getProcessSegment } from '../lib/formatters';
import { hasI18n, serializeI18n } from '../lib/i18n';
const { notifSend } = notifActions;
@ -349,15 +351,26 @@ export function discardDraft() {
return { type: DRAFT_DISCARD };
}
export function changeDraftField(
field: EntryField,
value: string,
metadata: Record<string, unknown>,
entries: EntryMap[],
) {
export function changeDraftField({
field,
value,
metadata,
entries,
i18n,
}: {
field: EntryField;
value: string;
metadata: Record<string, unknown>;
entries: EntryMap[];
i18n?: {
currentLocale: string;
defaultLocale: string;
locales: string[];
};
}) {
return {
type: DRAFT_CHANGE_FIELD,
payload: { field, value, metadata, entries },
payload: { field, value, metadata, entries, i18n },
};
}
@ -530,11 +543,13 @@ export function loadEntries(collection: Collection, page = 0) {
dispatch(entriesLoading(collection));
try {
const loadAllEntries = collection.has('nested') || hasI18n(collection);
let response: {
cursor: Cursor;
pagination: number;
entries: EntryValue[];
} = await (collection.has('nested')
} = await (loadAllEntries
? // nested collections require all entries to construct the tree
provider.listAllEntries(collection).then((entries: EntryValue[]) => ({ entries }))
: provider.listEntries(collection, page));
@ -760,6 +775,24 @@ export function getMediaAssets({ entry }: { entry: EntryMap }) {
return assets;
}
export const getSerializedEntry = (collection: Collection, entry: Entry) => {
/**
* Serialize the values of any fields with registered serializers, and
* update the entry and entryDraft with the serialized values.
*/
const fields = selectFields(collection, entry.get('slug'));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const serializeData = (data: any) => {
return serializeValues(data, fields);
};
const serializedData = serializeData(entry.get('data'));
let serializedEntry = entry.set('data', serializedData);
if (hasI18n(collection)) {
serializedEntry = serializeI18n(collection, serializedEntry, serializeData);
}
return serializedEntry;
};
export function persistEntry(collection: Collection) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
@ -794,13 +827,7 @@ export function persistEntry(collection: Collection) {
entry,
});
/**
* Serialize the values of any fields with registered serializers, and
* 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 serializedEntry = entry.set('data', serializedData);
const serializedEntry = getSerializedEntry(collection, entry);
const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
dispatch(entryPersisting(collection, serializedEntry));
return backend
@ -811,7 +838,7 @@ export function persistEntry(collection: Collection) {
assetProxies,
usedSlugs,
})
.then((newSlug: string) => {
.then(async (newSlug: string) => {
dispatch(
notifSend({
message: {
@ -821,16 +848,17 @@ export function persistEntry(collection: Collection) {
dismissAfter: 4000,
}),
);
// re-load media library if entry had media files
if (assetProxies.length > 0) {
dispatch(loadMedia());
await dispatch(loadMedia());
}
dispatch(entryPersisted(collection, serializedEntry, newSlug));
if (collection.has('nested')) {
dispatch(loadEntries(collection));
await dispatch(loadEntries(collection));
}
if (entry.get('slug') !== newSlug) {
dispatch(loadEntry(collection, newSlug));
await dispatch(loadEntry(collection, newSlug));
navigateToEntry(collection.get('name'), newSlug);
}
})

View File

@ -37,6 +37,8 @@ import {
asyncLock,
AsyncLock,
UnpublishedEntry,
DataFile,
UnpublishedEntryDiff,
} from 'netlify-cms-lib-util';
import { basename, join, extname, dirname } from 'path';
import { status } from './constants/publishModes';
@ -55,9 +57,41 @@ import {
import AssetProxy from './valueObjects/AssetProxy';
import { FOLDER, FILES } from './constants/collectionTypes';
import { selectCustomPath } from './reducers/entryDraft';
import {
getI18nFilesDepth,
getI18nFiles,
hasI18n,
getFilePaths,
getI18nEntry,
groupEntries,
getI18nDataFiles,
getI18nBackup,
formatI18nBackup,
} from './lib/i18n';
const { extractTemplateVars, dateParsers, expandPath } = stringTemplate;
const updateAssetProxies = (
assetProxies: AssetProxy[],
config: Config,
collection: Collection,
entryDraft: EntryDraft,
path: string,
) => {
assetProxies.map(asset => {
// update media files path based on entry path
const oldPath = asset.path;
const newPath = selectMediaFilePath(
config,
collection,
entryDraft.get('entry').set('path', path),
oldPath,
asset.field,
);
asset.path = newPath;
});
};
export class LocalStorageAuthStore {
storageKey = 'netlify-cms-user';
@ -223,6 +257,7 @@ interface BackupEntry {
raw: string;
path: string;
mediaFiles: MediaFile[];
i18n?: Record<string, { raw: string }>;
}
interface PersistArgs {
@ -253,6 +288,18 @@ const prepareMetaPath = (path: string, collection: Collection) => {
return dir.substr(collection.get('folder')!.length + 1) || '/';
};
const collectionDepth = (collection: Collection) => {
let depth;
depth =
collection.get('nested')?.get('depth') || getPathDepth(collection.get('path', '') as string);
if (hasI18n(collection)) {
depth = getI18nFilesDepth(collection, depth);
}
return depth;
};
export class Backend {
implementation: Implementation;
backendName: string;
@ -417,7 +464,6 @@ export class Backend {
}
processEntries(loadedEntries: ImplementationEntry[], collection: Collection) {
const collectionFilter = collection.get('filter');
const entries = loadedEntries.map(loadedEntry =>
createEntry(
collection.get('name'),
@ -433,9 +479,17 @@ export class Backend {
);
const formattedEntries = entries.map(this.entryWithFormat(collection));
// If this collection has a "filter" property, filter entries accordingly
const collectionFilter = collection.get('filter');
const filteredEntries = collectionFilter
? this.filterEntries({ entries: formattedEntries }, collectionFilter)
: formattedEntries;
if (hasI18n(collection)) {
const extension = selectFolderEntryExtension(collection);
const groupedEntries = groupEntries(collection, extension, entries);
return groupedEntries;
}
return filteredEntries;
}
@ -445,10 +499,7 @@ export class Backend {
const collectionType = collection.get('type');
if (collectionType === FOLDER) {
listMethod = () => {
const depth =
collection.get('nested')?.get('depth') ||
getPathDepth(collection.get('path', '') as string);
const depth = collectionDepth(collection);
return this.implementation.entriesByFolder(
collection.get('folder') as string,
extension,
@ -493,11 +544,8 @@ export class Backend {
// for local searches and queries.
async listAllEntries(collection: Collection) {
if (collection.get('folder') && this.implementation.allEntriesByFolder) {
const depth = collectionDepth(collection);
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)
.then(entries => this.processEntries(entries, collection));
@ -640,14 +688,23 @@ export class Backend {
});
const label = selectFileEntryLabel(collection, slug);
const entry: EntryValue = this.entryWithFormat(collection)(
createEntry(collection.get('name'), slug, path, {
raw,
label,
mediaFiles,
meta: { path: prepareMetaPath(path, collection) },
}),
);
const formatRawData = (raw: string) => {
return this.entryWithFormat(collection)(
createEntry(collection.get('name'), slug, path, {
raw,
label,
mediaFiles,
meta: { path: prepareMetaPath(path, collection) },
}),
);
};
const entry: EntryValue = formatRawData(raw);
if (hasI18n(collection) && backup.i18n) {
const i18n = formatI18nBackup(backup.i18n, formatRawData);
entry.i18n = i18n;
}
return { entry };
}
@ -676,10 +733,16 @@ export class Backend {
}),
);
let i18n;
if (hasI18n(collection)) {
i18n = getI18nBackup(collection, entry, entry => this.entryToRaw(collection, entry));
}
await localForage.setItem<BackupEntry>(key, {
raw,
path: entry.get('path'),
mediaFiles,
...(i18n && { i18n }),
});
const result = await localForage.setItem(getEntryBackupKey(), raw);
return result;
@ -714,18 +777,31 @@ export class Backend {
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 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 getEntryValue = async (path: string) => {
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) },
});
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;
};
let entryValue: EntryValue;
if (hasI18n(collection)) {
entryValue = await getI18nEntry(collection, extension, path, slug, getEntryValue);
} else {
entryValue = await getEntryValue(path);
}
return entryValue;
}
getMedia() {
@ -772,31 +848,6 @@ export class Backend {
} else {
extension = selectFolderEntryExtension(collection);
}
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 newFile = false;
let path = slug;
if (dataFiles.length <= 0) {
const loadedEntry = await this.implementation.getEntry(
selectEntryPath(collection, slug) as string,
);
data = loadedEntry.data;
path = loadedEntry.file.path;
} else {
const entryFile = dataFiles[0];
data = await this.implementation.unpublishedEntryDataFile(
collection.get('name'),
entryData.slug,
entryFile.path,
entryFile.id,
);
newFile = entryFile.newFile;
path = entryFile.path;
}
const mediaFiles: MediaFile[] = [];
if (withMediaFiles) {
@ -813,18 +864,58 @@ export class Backend {
);
mediaFiles.push(...files.map(f => ({ ...f, draft: true })));
}
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;
const dataFiles = sortBy(
entryData.diffs.filter(d => d.path.endsWith(extension)),
f => f.path.length,
);
const formatData = (data: string, path: string, newFile: boolean) => {
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;
};
const readAndFormatDataFile = async (dataFile: UnpublishedEntryDiff) => {
const data = await this.implementation.unpublishedEntryDataFile(
collection.get('name'),
entryData.slug,
dataFile.path,
dataFile.id,
);
const entryWithFormat = formatData(data, dataFile.path, dataFile.newFile);
return entryWithFormat;
};
// 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,
);
return formatData(loadedEntry.data, loadedEntry.file.path, false);
} else if (hasI18n(collection)) {
// we need to read all locales files and not just the changes
const path = selectEntryPath(collection, slug) as string;
const i18nFiles = getI18nDataFiles(collection, extension, path, slug, dataFiles);
let entries = await Promise.all(
i18nFiles.map(dataFile => readAndFormatDataFile(dataFile).catch(() => null)),
);
entries = entries.filter(Boolean);
const grouped = await groupEntries(collection, extension, entries as EntryValue[]);
return grouped[0];
} else {
const entryWithFormat = await readAndFormatDataFile(dataFiles[0]);
return entryWithFormat;
}
}
async unpublishedEntries(collections: Collections) {
@ -964,15 +1055,9 @@ export class Backend {
const useWorkflow = selectUseWorkflow(config);
let entryObj: {
path: string;
slug: string;
raw: string;
newPath?: string;
};
const customPath = selectCustomPath(collection, entryDraft);
let dataFile: DataFile;
if (newEntry) {
if (!selectAllowNewEntries(collection)) {
throw new Error('Not allowed to create new entries in this collection');
@ -985,27 +1070,16 @@ export class Backend {
customPath,
);
const path = customPath || (selectEntryPath(collection, slug) as string);
entryObj = {
dataFile = {
path,
slug,
raw: this.entryToRaw(collection, entryDraft.get('entry')),
};
assetProxies.map(asset => {
// update media files path based on entry path
const oldPath = asset.path;
const newPath = selectMediaFilePath(
config,
collection,
entryDraft.get('entry').set('path', path),
oldPath,
asset.field,
);
asset.path = newPath;
});
updateAssetProxies(assetProxies, config, collection, entryDraft, path);
} else {
const slug = entryDraft.getIn(['entry', 'slug']);
entryObj = {
dataFile = {
path: entryDraft.getIn(['entry', 'path']),
// for workflow entries we refresh the slug on publish
slug: customPath && !useWorkflow ? slugFromCustomPath(collection, customPath) : slug,
@ -1014,14 +1088,30 @@ export class Backend {
};
}
const { slug, path, newPath } = dataFile;
let dataFiles = [dataFile];
if (hasI18n(collection)) {
const extension = selectFolderEntryExtension(collection);
dataFiles = getI18nFiles(
collection,
extension,
entryDraft.get('entry'),
(draftData: EntryMap) => this.entryToRaw(collection, draftData),
path,
slug,
newPath,
);
}
const user = (await this.currentUser()) as User;
const commitMessage = commitMessageFormatter(
newEntry ? 'create' : 'update',
config,
{
collection,
slug: entryObj.slug,
path: entryObj.path,
slug,
path,
authorLogin: user.login,
authorName: user.name,
},
@ -1043,7 +1133,13 @@ export class Backend {
await this.invokePrePublishEvent(entryDraft.get('entry'));
}
await this.implementation.persistEntry(entryObj, assetProxies, opts);
await this.implementation.persistEntry(
{
dataFiles,
assets: assetProxies,
},
opts,
);
await this.invokePostSaveEvent(entryDraft.get('entry'));
@ -1051,7 +1147,7 @@ export class Backend {
await this.invokePostPublishEvent(entryDraft.get('entry'));
}
return entryObj.slug;
return slug;
}
async invokeEventWithEntry(event: string, entry: EntryMap) {
@ -1101,13 +1197,14 @@ export class Backend {
}
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;
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',
@ -1124,9 +1221,13 @@ export class Backend {
const entry = selectEntry(state.entries, collection.get('name'), slug);
await this.invokePreUnpublishEvent(entry);
const result = await this.implementation.deleteFile(path, commitMessage);
let paths = [path];
if (hasI18n(collection)) {
paths = getFilePaths(collection, extension, path, slug);
}
await this.implementation.deleteFiles(paths, commitMessage);
await this.invokePostUnpublishEvent(entry);
return result;
}
async deleteMedia(config: Config, path: string) {
@ -1141,7 +1242,7 @@ export class Backend {
},
user.useOpenAuthoring,
);
return this.implementation.deleteFile(path, commitMessage);
return this.implementation.deleteFiles([path], commitMessage);
}
persistUnpublishedEntry(args: PersistArgs) {

View File

@ -196,9 +196,9 @@ export class Editor extends React.Component {
this.props.persistLocalBackup(entry, collection);
}, 2000);
handleChangeDraftField = (field, value, metadata) => {
handleChangeDraftField = (field, value, metadata, i18n) => {
const entries = [this.props.unPublishedEntry, this.props.publishedEntry].filter(Boolean);
this.props.changeDraftField(field, value, metadata, entries);
this.props.changeDraftField({ field, value, metadata, entries, i18n });
};
handleChangeStatus = newStatusName => {
@ -418,6 +418,7 @@ export class Editor extends React.Component {
deployPreview={deployPreview}
loadDeployPreview={opts => loadDeployPreview(collection, slug, entry, isPublished, opts)}
editorBackLink={editorBackLink}
t={t}
/>
);
}

View File

@ -56,6 +56,14 @@ const styleStrings = {
widgetError: `
border-color: ${colors.errorText};
`,
disabled: `
pointer-events: none;
opacity: 0.5;
background: #ccc;
`,
hidden: `
visibility: hidden;
`,
};
const ControlContainer = styled.div`
@ -87,6 +95,17 @@ export const ControlHint = styled.p`
transition: color ${transitions.main};
`;
const LabelComponent = ({ field, isActive, hasErrors, uniqueFieldId, isFieldOptional, t }) => {
const label = `${field.get('label', field.get('name'))}`;
const labelComponent = (
<FieldLabel isActive={isActive} hasErrors={hasErrors} htmlFor={uniqueFieldId}>
{label} {`${isFieldOptional ? ` (${t('editor.editorControl.field.optional')})` : ''}`}
</FieldLabel>
);
return labelComponent;
};
class EditorControl extends React.Component {
static propTypes = {
value: PropTypes.oneOfType([
@ -119,6 +138,10 @@ class EditorControl extends React.Component {
parentIds: PropTypes.arrayOf(PropTypes.string),
entry: ImmutablePropTypes.map.isRequired,
collection: ImmutablePropTypes.map.isRequired,
isDisabled: PropTypes.bool,
isHidden: PropTypes.bool,
isFieldDuplicate: PropTypes.func,
isFieldHidden: PropTypes.func,
};
static defaultProps = {
@ -175,6 +198,10 @@ class EditorControl extends React.Component {
parentIds,
t,
validateMetaField,
isDisabled,
isHidden,
isFieldDuplicate,
isFieldHidden,
} = this.props;
const widgetName = field.get('widget');
@ -191,7 +218,12 @@ class EditorControl extends React.Component {
return (
<ClassNames>
{({ css, cx }) => (
<ControlContainer className={className}>
<ControlContainer
className={className}
css={css`
${isHidden && styleStrings.hidden};
`}
>
{widget.globalStyles && <Global styles={coreCss`${widget.globalStyles}`} />}
{errors && (
<ControlErrorsList>
@ -206,15 +238,14 @@ class EditorControl extends React.Component {
)}
</ControlErrorsList>
)}
<FieldLabel
<LabelComponent
field={field}
isActive={isSelected || this.state.styleActive}
hasErrors={hasErrors}
htmlFor={this.uniqueFieldId}
>
{`${field.get('label', field.get('name'))}${
isFieldOptional ? ` (${t('editor.editorControl.field.optional')})` : ''
}`}
</FieldLabel>
uniqueFieldId={this.uniqueFieldId}
isFieldOptional={isFieldOptional}
t={t}
/>
<Widget
classNameWrapper={cx(
css`
@ -230,6 +261,11 @@ class EditorControl extends React.Component {
${styleStrings.widgetError};
`]: hasErrors,
},
{
[css`
${styleStrings.disabled}
`]: isDisabled,
},
)}
classNameWidget={css`
${styleStrings.widget};
@ -282,6 +318,9 @@ class EditorControl extends React.Component {
parentIds={parentIds}
t={t}
validateMetaField={validateMetaField}
isDisabled={isDisabled}
isFieldDuplicate={isFieldDuplicate}
isFieldHidden={isFieldHidden}
/>
{fieldHint && (
<ControlHint active={isSelected || this.state.styleActive} error={hasErrors}>

View File

@ -1,8 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { css } from '@emotion/core';
import styled from '@emotion/styled';
import EditorControl from './EditorControl';
import {
colors,
Dropdown,
DropdownItem,
StyledDropdownButton,
buttons,
text,
} from 'netlify-cms-ui-default';
import {
getI18nInfo,
isFieldTranslatable,
isFieldDuplicate,
isFieldHidden,
getLocaleDataPath,
hasI18n,
} from '../../../lib/i18n';
const ControlPaneContainer = styled.div`
max-width: 800px;
@ -11,7 +28,75 @@ const ControlPaneContainer = styled.div`
font-size: 16px;
`;
const LocaleButton = styled(StyledDropdownButton)`
${buttons.button};
${buttons.medium};
color: ${colors.controlLabel};
background: ${colors.textFieldBorder};
height: 100%;
&:after {
top: 11px;
}
`;
const LocaleButtonWrapper = styled.div`
display: flex;
`;
const StyledDropdown = styled(Dropdown)`
width: max-content;
margin-top: 20px;
margin-bottom: 20px;
`;
const LocaleDropdown = ({ locales, selectedLocale, onLocaleChange, t }) => {
return (
<StyledDropdown
renderButton={() => {
return (
<LocaleButtonWrapper>
<LocaleButton>
{t('editor.editorControlPane.i18n.writingInLocale', {
locale: selectedLocale.toUpperCase(),
})}
</LocaleButton>
</LocaleButtonWrapper>
);
}}
>
{locales.map(l => (
<DropdownItem
css={css`
${text.fieldLabel}
`}
key={l}
label={l}
onClick={() => onLocaleChange(l)}
/>
))}
</StyledDropdown>
);
};
const getFieldValue = ({ field, entry, isTranslatable, locale }) => {
if (field.get('meta')) {
return entry.getIn(['meta', field.get('name')]);
}
if (isTranslatable) {
const dataPath = getLocaleDataPath(locale);
return entry.getIn([...dataPath, field.get('name')]);
}
return entry.getIn(['data', field.get('name')]);
};
export default class ControlPane extends React.Component {
state = {
selectedLocale: this.props.locale,
};
componentValidate = {};
controlRef(field, wrappedControl) {
@ -22,23 +107,29 @@ export default class ControlPane extends React.Component {
wrappedControl.innerWrappedControl?.validate || wrappedControl.validate;
}
validate = () => {
handleLocaleChange = val => {
this.setState({ selectedLocale: val });
};
validate = async () => {
this.props.fields.forEach(field => {
if (field.get('widget') === 'hidden') return;
this.componentValidate[field.get('name')]();
});
};
switchToDefaultLocale = () => {
if (hasI18n(this.props.collection)) {
const { defaultLocale } = getI18nInfo(this.props.collection);
return new Promise(resolve => this.setState({ selectedLocale: defaultLocale }, resolve));
} else {
return Promise.resolve();
}
};
render() {
const {
collection,
fields,
entry,
fieldsMetaData,
fieldsErrors,
onChange,
onValidate,
} = this.props;
const { collection, entry, fieldsMetaData, fieldsErrors, onChange, onValidate, t } = this.props;
const fields = this.props.fields;
if (!collection || !fields) {
return null;
@ -48,29 +139,59 @@ export default class ControlPane extends React.Component {
return null;
}
const { locales, defaultLocale } = getI18nInfo(collection);
const locale = this.state.selectedLocale;
const i18n = locales && {
currentLocale: locale,
locales,
defaultLocale,
};
return (
<ControlPaneContainer>
{fields.map((field, i) => {
return field.get('widget') === 'hidden' ? null : (
<EditorControl
key={i}
field={field}
value={
field.get('meta')
? entry.getIn(['meta', field.get('name')])
: entry.getIn(['data', field.get('name')])
}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
onChange={onChange}
onValidate={onValidate}
processControlRef={this.controlRef.bind(this)}
controlRef={this.controlRef}
entry={entry}
collection={collection}
/>
);
})}
{locales && (
<LocaleDropdown
locales={locales}
selectedLocale={locale}
onLocaleChange={this.handleLocaleChange}
t={t}
/>
)}
{fields
.filter(f => f.get('widget') !== 'hidden')
.map((field, i) => {
const isTranslatable = isFieldTranslatable(field, locale, defaultLocale);
const isDuplicate = isFieldDuplicate(field, locale, defaultLocale);
const isHidden = isFieldHidden(field, locale, defaultLocale);
const key = i18n ? `${locale}_${i}` : i;
return (
<EditorControl
key={key}
field={field}
value={getFieldValue({
field,
entry,
locale,
isTranslatable,
})}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
onChange={(field, newValue, newMetadata) =>
onChange(field, newValue, newMetadata, i18n)
}
onValidate={onValidate}
processControlRef={this.controlRef.bind(this)}
controlRef={this.controlRef}
entry={entry}
collection={collection}
isDisabled={isDuplicate}
isHidden={isHidden}
isFieldDuplicate={field => isFieldDuplicate(field, locale, defaultLocale)}
isFieldHidden={field => isFieldHidden(field, locale, defaultLocale)}
/>
);
})}
</ControlPaneContainer>
);
}

View File

@ -60,6 +60,9 @@ export default class Widget extends Component {
isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool,
entry: ImmutablePropTypes.map.isRequired,
isDisabled: PropTypes.bool,
isFieldDuplicate: PropTypes.func,
isFieldHidden: PropTypes.func,
};
shouldComponentUpdate(nextProps) {
@ -277,6 +280,9 @@ export default class Widget extends Component {
isNewEditorComponent,
parentIds,
t,
isDisabled,
isFieldDuplicate,
isFieldHidden,
} = this.props;
return React.createElement(controlComponent, {
@ -323,6 +329,9 @@ export default class Widget extends Component {
controlRef,
parentIds,
t,
isDisabled,
isFieldDuplicate,
isFieldHidden,
});
}
}

View File

@ -16,10 +16,12 @@ import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync';
import EditorControlPane from './EditorControlPane/EditorControlPane';
import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane';
import EditorToolbar from './EditorToolbar';
import { hasI18n, getI18nInfo, getPreviewEntry } from '../../lib/i18n';
const PREVIEW_VISIBLE = 'cms.preview-visible';
const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled';
const SPLIT_PANE_POSITION = 'cms.split-pane-position';
const I18N_VISIBLE = 'cms.i18n-visible';
const styles = {
splitPane: css`
@ -100,8 +102,8 @@ const Editor = styled.div`
const PreviewPaneContainer = styled.div`
height: 100%;
overflow-y: auto;
pointer-events: ${props => (props.blockEntry ? 'none' : 'auto')};
overflow-y: ${props => (props.overFlow ? 'auto' : 'hidden')};
`;
const ControlPaneContainer = styled(PreviewPaneContainer)`
@ -117,11 +119,28 @@ const ViewControls = styled.div`
z-index: ${zIndex.zIndex299};
`;
const EditorContent = ({
i18nVisible,
previewVisible,
editor,
editorWithEditor,
editorWithPreview,
}) => {
if (i18nVisible) {
return editorWithEditor;
} else if (previewVisible) {
return editorWithPreview;
} else {
return <NoPreviewContainer>{editor}</NoPreviewContainer>;
}
};
class EditorInterface extends Component {
state = {
showEventBlocker: false,
previewVisible: localStorage.getItem(PREVIEW_VISIBLE) !== 'false',
scrollSyncEnabled: localStorage.getItem(SCROLL_SYNC_ENABLED) !== 'false',
i18nVisible: localStorage.getItem(I18N_VISIBLE) !== 'false',
};
handleSplitPaneDragStart = () => {
@ -132,14 +151,16 @@ class EditorInterface extends Component {
this.setState({ showEventBlocker: false });
};
handleOnPersist = (opts = {}) => {
handleOnPersist = async (opts = {}) => {
const { createNew = false, duplicate = false } = opts;
await this.controlPaneRef.switchToDefaultLocale();
this.controlPaneRef.validate();
this.props.onPersist({ createNew, duplicate });
};
handleOnPublish = (opts = {}) => {
handleOnPublish = async (opts = {}) => {
const { createNew = false, duplicate = false } = opts;
await this.controlPaneRef.switchToDefaultLocale();
this.controlPaneRef.validate();
this.props.onPublish({ createNew, duplicate });
};
@ -156,6 +177,16 @@ class EditorInterface extends Component {
localStorage.setItem(SCROLL_SYNC_ENABLED, newScrollSyncEnabled);
};
handleToggleI18n = () => {
const newI18nVisible = !this.state.i18nVisible;
this.setState({ i18nVisible: newI18nVisible });
localStorage.setItem(I18N_VISIBLE, newI18nVisible);
};
handleLeftPanelLocaleChange = locale => {
this.setState({ leftPanelLocale: locale });
};
render() {
const {
collection,
@ -186,27 +217,46 @@ class EditorInterface extends Component {
deployPreview,
draftKey,
editorBackLink,
t,
} = this.props;
const { previewVisible, scrollSyncEnabled, showEventBlocker } = this.state;
const { scrollSyncEnabled, showEventBlocker } = this.state;
const collectionPreviewEnabled = collection.getIn(['editor', 'preview'], true);
const collectionI18nEnabled = hasI18n(collection);
const { locales, defaultLocale } = getI18nInfo(this.props.collection);
const editorProps = {
collection,
entry,
fields,
fieldsMetaData,
fieldsErrors,
onChange,
onValidate,
};
const leftPanelLocale = this.state.leftPanelLocale || locales?.[0];
const editor = (
<ControlPaneContainer blockEntry={showEventBlocker}>
<ControlPaneContainer overFlow blockEntry={showEventBlocker}>
<EditorControlPane
collection={collection}
entry={entry}
fields={fields}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
onChange={onChange}
onValidate={onValidate}
{...editorProps}
ref={c => (this.controlPaneRef = c)}
locale={leftPanelLocale}
t={t}
onLocaleChange={this.handleLeftPanelLocaleChange}
/>
</ControlPaneContainer>
);
const editor2 = (
<ControlPaneContainer overFlow={!this.state.scrollSyncEnabled} blockEntry={showEventBlocker}>
<EditorControlPane {...editorProps} locale={locales?.[1]} t={t} />
</ControlPaneContainer>
);
const previewEntry = collectionI18nEnabled
? getPreviewEntry(entry, leftPanelLocale, defaultLocale)
: entry;
const editorWithPreview = (
<ScrollSync enabled={this.state.scrollSyncEnabled}>
<div>
@ -222,7 +272,7 @@ class EditorInterface extends Component {
<PreviewPaneContainer blockEntry={showEventBlocker}>
<EditorPreviewPane
collection={collection}
entry={entry}
entry={previewEntry}
fields={fields}
fieldsMetaData={fieldsMetaData}
/>
@ -232,6 +282,27 @@ class EditorInterface extends Component {
</ScrollSync>
);
const editorWithEditor = (
<ScrollSync enabled={this.state.scrollSyncEnabled}>
<div>
<StyledSplitPane
maxSize={-100}
defaultSize={parseInt(localStorage.getItem(SPLIT_PANE_POSITION), 10) || '50%'}
onChange={size => localStorage.setItem(SPLIT_PANE_POSITION, size)}
onDragStarted={this.handleSplitPaneDragStart}
onDragFinished={this.handleSplitPaneDragFinished}
>
<ScrollSyncPane>{editor}</ScrollSyncPane>
<ScrollSyncPane>{editor2}</ScrollSyncPane>
</StyledSplitPane>
</div>
</ScrollSync>
);
const i18nVisible = collectionI18nEnabled && this.state.i18nVisible;
const previewVisible = collectionPreviewEnabled && this.state.previewVisible;
const scrollSyncVisible = i18nVisible || previewVisible;
return (
<EditorContainer>
<EditorToolbar
@ -268,6 +339,16 @@ class EditorInterface extends Component {
/>
<Editor key={draftKey}>
<ViewControls>
{collectionI18nEnabled && (
<EditorToggle
isActive={i18nVisible}
onClick={this.handleToggleI18n}
size="large"
type="page"
title="Toggle i18n"
marginTop="70px"
/>
)}
{collectionPreviewEnabled && (
<EditorToggle
isActive={previewVisible}
@ -277,7 +358,7 @@ class EditorInterface extends Component {
title="Toggle preview"
/>
)}
{collectionPreviewEnabled && previewVisible && (
{scrollSyncVisible && (
<EditorToggle
isActive={scrollSyncEnabled}
onClick={this.handleToggleScrollSync}
@ -287,11 +368,13 @@ class EditorInterface extends Component {
/>
)}
</ViewControls>
{collectionPreviewEnabled && this.state.previewVisible ? (
editorWithPreview
) : (
<NoPreviewContainer>{editor}</NoPreviewContainer>
)}
<EditorContent
i18nVisible={i18nVisible}
previewVisible={previewVisible}
editor={editor}
editorWithEditor={editorWithEditor}
editorWithPreview={editorWithPreview}
/>
</Editor>
</EditorContainer>
);
@ -327,6 +410,7 @@ EditorInterface.propTypes = {
deployPreview: ImmutablePropTypes.map,
loadDeployPreview: PropTypes.func.isRequired,
draftKey: PropTypes.string.isRequired,
t: PropTypes.func.isRequired,
};
export default EditorInterface;

View File

@ -450,5 +450,59 @@ describe('config', () => {
);
}).not.toThrow();
});
describe('i18n', () => {
it('should throw error when locale has invalid characters', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
i18n: {
structure: 'multiple_folders',
locales: ['en', 'tr.TR'],
},
}),
);
}).toThrowError(`'i18n.locales[1]' should match pattern "^[a-zA-Z-_]+$"`);
});
it('should throw error when locale is less than 2 characters', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
i18n: {
structure: 'multiple_folders',
locales: ['en', 't'],
},
}),
);
}).toThrowError(`'i18n.locales[1]' should NOT be shorter than 2 characters`);
});
it('should throw error when locale is more than 10 characters', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
i18n: {
structure: 'multiple_folders',
locales: ['en', 'a_very_long_locale'],
},
}),
);
}).toThrowError(`'i18n.locales[1]' should NOT be longer than 10 characters`);
});
it('should allow valid locales strings', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
i18n: {
structure: 'multiple_folders',
locales: ['en', 'tr-TR', 'zh_CHS'],
},
}),
);
}).not.toThrow();
});
});
});
});

View File

@ -1,8 +1,43 @@
import AJV from 'ajv';
import { select, uniqueItemProperties, instanceof as instanceOf } from 'ajv-keywords/keywords';
import {
select,
uniqueItemProperties,
instanceof as instanceOf,
prohibited,
} from 'ajv-keywords/keywords';
import ajvErrors from 'ajv-errors';
import { formatExtensions, frontmatterFormats, extensionFormatters } from 'Formats/formats';
import { getWidgets } from 'Lib/registry';
import { I18N_STRUCTURE, I18N_FIELD } from '../lib/i18n';
const localeType = { type: 'string', minLength: 2, maxLength: 10, pattern: '^[a-zA-Z-_]+$' };
const i18n = {
type: 'object',
properties: {
structure: { type: 'string', enum: Object.values(I18N_STRUCTURE) },
locales: {
type: 'array',
minItems: 2,
items: localeType,
uniqueItems: true,
},
default_locale: localeType,
},
};
const i18nRoot = {
...i18n,
required: ['structure', 'locales'],
};
const i18nCollection = {
oneOf: [{ type: 'boolean' }, i18n],
};
const i18nField = {
oneOf: [{ type: 'boolean' }, { type: 'string', enum: Object.values(I18N_FIELD) }],
};
/**
* Config for fields in both file and folder collections.
@ -20,6 +55,7 @@ const fieldsConfig = () => ({
label: { type: 'string' },
widget: { type: 'string' },
required: { type: 'boolean' },
i18n: i18nField,
hint: { type: 'string' },
pattern: {
type: 'array',
@ -100,6 +136,7 @@ const getConfigSchema = () => ({
],
},
locale: { type: 'string', examples: ['en', 'fr', 'de'] },
i18n: i18nRoot,
site_url: { type: 'string', examples: ['https://example.com'] },
display_url: { type: 'string', examples: ['https://example.com'] },
logo_url: { type: 'string', examples: ['https://example.com/images/logo.svg'] },
@ -219,6 +256,7 @@ const getConfigSchema = () => ({
additionalProperties: false,
minProperties: 1,
},
i18n: i18nCollection,
},
required: ['name', 'label'],
oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }],
@ -289,6 +327,7 @@ export function validateConfig(config) {
uniqueItemProperties(ajv);
select(ajv);
instanceOf(ajv);
prohibited(ajv);
ajvErrors(ajv);
const valid = ajv.validate(getConfigSchema(), config);

View File

@ -0,0 +1,706 @@
import { fromJS } from 'immutable';
import * as i18n from '../i18n';
jest.mock('../../reducers/collections', () => {
return {
selectEntrySlug: () => 'index',
};
});
describe('i18n', () => {
describe('hasI18n', () => {
it('should return false for collection with no i18n', () => {
expect(i18n.hasI18n(fromJS({}))).toBe(false);
});
it('should return true for collection with i18n', () => {
expect(i18n.hasI18n(fromJS({ i18n: { structure: i18n.I18N_STRUCTURE.SINGLE_FILE } }))).toBe(
true,
);
});
});
describe('getI18nInfo', () => {
it('should return empty object for collection with no i18n', () => {
expect(i18n.getI18nInfo(fromJS({}))).toEqual({});
});
it('should return i18n object for collection with i18n', () => {
const i18nObject = {
locales: ['en', 'de'],
default_locale: 'en',
structure: i18n.I18N_STRUCTURE.MULTIPLE_FOLDERS,
};
expect(i18n.getI18nInfo(fromJS({ i18n: i18nObject }))).toEqual({
locales: ['en', 'de'],
defaultLocale: 'en',
structure: i18n.I18N_STRUCTURE.MULTIPLE_FOLDERS,
});
});
});
describe('getI18nFilesDepth', () => {
it('should increase depth when i18n structure is I18N_STRUCTURE.MULTIPLE_FOLDERS', () => {
expect(
i18n.getI18nFilesDepth(
fromJS({ i18n: { structure: i18n.I18N_STRUCTURE.MULTIPLE_FOLDERS } }),
5,
),
).toBe(6);
});
it('should return current depth when i18n structure is not I18N_STRUCTURE.MULTIPLE_FOLDERS', () => {
expect(
i18n.getI18nFilesDepth(
fromJS({ i18n: { structure: i18n.I18N_STRUCTURE.MULTIPLE_FILES } }),
5,
),
).toBe(5);
expect(
i18n.getI18nFilesDepth(fromJS({ i18n: { structure: i18n.I18N_STRUCTURE.SINGLE_FILE } }), 5),
).toBe(5);
expect(i18n.getI18nFilesDepth(fromJS({}), 5)).toBe(5);
});
});
describe('isFieldTranslatable', () => {
it('should return true when not default locale and has I18N_FIELD.TRANSLATE', () => {
expect(
i18n.isFieldTranslatable(fromJS({ i18n: i18n.I18N_FIELD.TRANSLATE }), 'en', 'de'),
).toBe(true);
});
it('should return false when default locale and has I18N_FIELD.TRANSLATE', () => {
expect(
i18n.isFieldTranslatable(fromJS({ i18n: i18n.I18N_FIELD.TRANSLATE }), 'en', 'en'),
).toBe(false);
});
it("should return false when doesn't have i18n", () => {
expect(i18n.isFieldTranslatable(fromJS({}), 'en', 'en')).toBe(false);
});
});
describe('isFieldDuplicate', () => {
it('should return true when not default locale and has I18N_FIELD.TRANSLATE', () => {
expect(i18n.isFieldDuplicate(fromJS({ i18n: i18n.I18N_FIELD.DUPLICATE }), 'en', 'de')).toBe(
true,
);
});
it('should return false when default locale and has I18N_FIELD.TRANSLATE', () => {
expect(i18n.isFieldDuplicate(fromJS({ i18n: i18n.I18N_FIELD.DUPLICATE }), 'en', 'en')).toBe(
false,
);
});
it("should return false when doesn't have i18n", () => {
expect(i18n.isFieldDuplicate(fromJS({}), 'en', 'en')).toBe(false);
});
});
describe('isFieldHidden', () => {
it('should return true when not default locale and has I18N_FIELD.NONE', () => {
expect(i18n.isFieldHidden(fromJS({ i18n: i18n.I18N_FIELD.NONE }), 'en', 'de')).toBe(true);
});
it('should return false when default locale and has I18N_FIELD.NONE', () => {
expect(i18n.isFieldHidden(fromJS({ i18n: i18n.I18N_FIELD.NONE }), 'en', 'en')).toBe(false);
});
it("should return false when doesn't have i18n", () => {
expect(i18n.isFieldHidden(fromJS({}), 'en', 'en')).toBe(false);
});
});
describe('getLocaleDataPath', () => {
it('should return string array with locale as part of the data path', () => {
expect(i18n.getLocaleDataPath('de')).toEqual(['i18n', 'de', 'data']);
});
});
describe('getDataPath', () => {
it('should not include locale in path for default locale', () => {
expect(i18n.getDataPath('en', 'en')).toEqual(['data']);
});
it('should include locale in path for non default locale', () => {
expect(i18n.getDataPath('de', 'en')).toEqual(['i18n', 'de', 'data']);
});
});
describe('getFilePath', () => {
const args = ['md', 'src/content/index.md', 'index', 'de'];
it('should return directory path based on locale when structure is I18N_STRUCTURE.MULTIPLE_FOLDERS', () => {
expect(i18n.getFilePath(i18n.I18N_STRUCTURE.MULTIPLE_FOLDERS, ...args)).toEqual(
'src/content/de/index.md',
);
});
it('should return file path based on locale when structure is I18N_STRUCTURE.MULTIPLE_FILES', () => {
expect(i18n.getFilePath(i18n.I18N_STRUCTURE.MULTIPLE_FILES, ...args)).toEqual(
'src/content/index.de.md',
);
});
it('should not modify path when structure is I18N_STRUCTURE.SINGLE_FILE', () => {
expect(i18n.getFilePath(i18n.I18N_STRUCTURE.SINGLE_FILE, ...args)).toEqual(
'src/content/index.md',
);
});
});
describe('getFilePaths', () => {
const args = ['md', 'src/content/index.md', 'index'];
it('should return file paths for all locales', () => {
expect(
i18n.getFilePaths(
fromJS({
i18n: { structure: i18n.I18N_STRUCTURE.MULTIPLE_FOLDERS, locales: ['en', 'de'] },
}),
...args,
),
).toEqual(['src/content/en/index.md', 'src/content/de/index.md']);
});
});
describe('normalizeFilePath', () => {
it('should remove locale folder from path when structure is I18N_STRUCTURE.MULTIPLE_FOLDERS', () => {
expect(
i18n.normalizeFilePath(
i18n.I18N_STRUCTURE.MULTIPLE_FOLDERS,
'src/content/en/index.md',
'en',
),
).toEqual('src/content/index.md');
});
it('should remove locale extension from path when structure is I18N_STRUCTURE.MULTIPLE_FILES', () => {
expect(
i18n.normalizeFilePath(i18n.I18N_STRUCTURE.MULTIPLE_FILES, 'src/content/index.en.md', 'en'),
).toEqual('src/content/index.md');
});
it('should not modify path when structure is I18N_STRUCTURE.SINGLE_FILE', () => {
expect(
i18n.normalizeFilePath(i18n.I18N_STRUCTURE.SINGLE_FILE, 'src/content/index.md', 'en'),
).toEqual('src/content/index.md');
});
});
describe('getI18nFiles', () => {
const locales = ['en', 'de', 'fr'];
const default_locale = 'en';
const args = [
'md',
fromJS({
data: { title: 'en_title' },
i18n: { de: { data: { title: 'de_title' } }, fr: { data: { title: 'fr_title' } } },
}),
map => map.get('data').toJS(),
'src/content/index.md',
'index',
];
it('should return a single file when structure is I18N_STRUCTURE.SINGLE_FILE', () => {
expect(
i18n.getI18nFiles(
fromJS({ i18n: { structure: i18n.I18N_STRUCTURE.SINGLE_FILE, locales, default_locale } }),
...args,
),
).toEqual([
{
path: 'src/content/index.md',
raw: {
en: { title: 'en_title' },
de: { title: 'de_title' },
fr: { title: 'fr_title' },
},
slug: 'index',
},
]);
});
it('should return a folder based files when structure is I18N_STRUCTURE.MULTIPLE_FOLDERS', () => {
expect(
i18n.getI18nFiles(
fromJS({
i18n: { structure: i18n.I18N_STRUCTURE.MULTIPLE_FOLDERS, locales, default_locale },
}),
...args,
),
).toEqual([
{
path: 'src/content/en/index.md',
raw: { title: 'en_title' },
slug: 'index',
},
{
path: 'src/content/de/index.md',
raw: { title: 'de_title' },
slug: 'index',
},
{
path: 'src/content/fr/index.md',
raw: { title: 'fr_title' },
slug: 'index',
},
]);
});
it('should return a extension based files when structure is I18N_STRUCTURE.MULTIPLE_FILES', () => {
expect(
i18n.getI18nFiles(
fromJS({
i18n: { structure: i18n.I18N_STRUCTURE.MULTIPLE_FILES, locales, default_locale },
}),
...args,
),
).toEqual([
{
path: 'src/content/index.en.md',
raw: { title: 'en_title' },
slug: 'index',
},
{
path: 'src/content/index.de.md',
raw: { title: 'de_title' },
slug: 'index',
},
{
path: 'src/content/index.fr.md',
raw: { title: 'fr_title' },
slug: 'index',
},
]);
});
});
describe('getI18nEntry', () => {
const locales = ['en', 'de', 'fr', 'es'];
const default_locale = 'en';
const args = ['md', 'src/content/index.md', 'index'];
it('should return i18n entry content when structure is I18N_STRUCTURE.MULTIPLE_FOLDERS', async () => {
const data = {
'src/content/en/index.md': {
slug: 'index',
path: 'src/content/en/index.md',
data: { title: 'en_title' },
},
'src/content/de/index.md': {
slug: 'index',
path: 'src/content/de/index.md',
data: { title: 'de_title' },
},
'src/content/fr/index.md': {
slug: 'index',
path: 'src/content/fr/index.md',
data: { title: 'fr_title' },
},
};
const getEntryValue = jest.fn(path =>
data[path] ? Promise.resolve(data[path]) : Promise.reject('Not found'),
);
await expect(
i18n.getI18nEntry(
fromJS({
i18n: { structure: i18n.I18N_STRUCTURE.MULTIPLE_FOLDERS, locales, default_locale },
}),
...args,
getEntryValue,
),
).resolves.toEqual({
slug: 'index',
path: 'src/content/index.md',
data: { title: 'en_title' },
i18n: {
de: { data: { title: 'de_title' } },
fr: { data: { title: 'fr_title' } },
},
raw: '',
});
});
it('should return i18n entry content when structure is I18N_STRUCTURE.MULTIPLE_FILES', async () => {
const data = {
'src/content/index.en.md': {
slug: 'index',
path: 'src/content/index.en.md',
data: { title: 'en_title' },
},
'src/content/index.de.md': {
slug: 'index',
path: 'src/content/index.de.md',
data: { title: 'de_title' },
},
'src/content/index.fr.md': {
slug: 'index',
path: 'src/content/index.fr.md',
data: { title: 'fr_title' },
},
};
const getEntryValue = jest.fn(path =>
data[path] ? Promise.resolve(data[path]) : Promise.reject('Not found'),
);
await expect(
i18n.getI18nEntry(
fromJS({
i18n: { structure: i18n.I18N_STRUCTURE.MULTIPLE_FILES, locales, default_locale },
}),
...args,
getEntryValue,
),
).resolves.toEqual({
slug: 'index',
path: 'src/content/index.md',
data: { title: 'en_title' },
i18n: {
de: { data: { title: 'de_title' } },
fr: { data: { title: 'fr_title' } },
},
raw: '',
});
});
it('should return single entry content when structure is I18N_STRUCTURE.SINGLE_FILE', async () => {
const data = {
'src/content/index.md': {
slug: 'index',
path: 'src/content/index.md',
data: {
en: { title: 'en_title' },
de: { title: 'de_title' },
fr: { title: 'fr_title' },
},
},
};
const getEntryValue = jest.fn(path => Promise.resolve(data[path]));
await expect(
i18n.getI18nEntry(
fromJS({
i18n: { structure: i18n.I18N_STRUCTURE.SINGLE_FILE, locales, default_locale },
}),
...args,
getEntryValue,
),
).resolves.toEqual({
slug: 'index',
path: 'src/content/index.md',
data: {
title: 'en_title',
},
i18n: {
de: { data: { title: 'de_title' } },
fr: { data: { title: 'fr_title' } },
},
raw: '',
});
});
});
describe('groupEntries', () => {
const locales = ['en', 'de', 'fr'];
const default_locale = 'en';
const extension = 'md';
it('should group entries array when structure is I18N_STRUCTURE.MULTIPLE_FOLDERS', () => {
const entries = [
{
slug: 'index',
path: 'src/content/en/index.md',
data: { title: 'en_title' },
},
{
slug: 'index',
path: 'src/content/de/index.md',
data: { title: 'de_title' },
},
{
slug: 'index',
path: 'src/content/fr/index.md',
data: { title: 'fr_title' },
},
];
expect(
i18n.groupEntries(
fromJS({
i18n: { structure: i18n.I18N_STRUCTURE.MULTIPLE_FOLDERS, locales, default_locale },
}),
extension,
entries,
),
).toEqual([
{
slug: 'index',
path: 'src/content/index.md',
data: { title: 'en_title' },
i18n: { de: { data: { title: 'de_title' } }, fr: { data: { title: 'fr_title' } } },
raw: '',
},
]);
});
it('should group entries array when structure is I18N_STRUCTURE.MULTIPLE_FILES', () => {
const entries = [
{
slug: 'index',
path: 'src/content/index.en.md',
data: { title: 'en_title' },
},
{
slug: 'index',
path: 'src/content/index.de.md',
data: { title: 'de_title' },
},
{
slug: 'index',
path: 'src/content/index.fr.md',
data: { title: 'fr_title' },
},
];
expect(
i18n.groupEntries(
fromJS({
i18n: { structure: i18n.I18N_STRUCTURE.MULTIPLE_FILES, locales, default_locale },
}),
extension,
entries,
),
).toEqual([
{
slug: 'index',
path: 'src/content/index.md',
data: { title: 'en_title' },
i18n: { de: { data: { title: 'de_title' } }, fr: { data: { title: 'fr_title' } } },
raw: '',
},
]);
});
it('should return entries array as is when structure is I18N_STRUCTURE.SINGLE_FILE', () => {
const entries = [
{
slug: 'index',
path: 'src/content/index.md',
data: {
en: { title: 'en_title' },
de: { title: 'de_title' },
fr: { title: 'fr_title' },
},
},
];
expect(
i18n.groupEntries(
fromJS({
i18n: { structure: i18n.I18N_STRUCTURE.SINGLE_FILE, locales, default_locale },
}),
extension,
entries,
),
).toEqual([
{
slug: 'index',
path: 'src/content/index.md',
data: {
title: 'en_title',
},
i18n: { de: { data: { title: 'de_title' } }, fr: { data: { title: 'fr_title' } } },
raw: '',
},
]);
});
});
describe('getI18nDataFiles', () => {
const locales = ['en', 'de', 'fr'];
const default_locale = 'en';
const args = ['md', 'src/content/index.md', 'index'];
it('should add missing locale files to diff files when structure is MULTIPLE_FOLDERS', () => {
expect(
i18n.getI18nDataFiles(
fromJS({
i18n: { structure: i18n.I18N_STRUCTURE.MULTIPLE_FOLDERS, locales, default_locale },
}),
...args,
[{ path: 'src/content/fr/index.md', id: 'id', newFile: false }],
),
).toEqual([
{ path: 'src/content/en/index.md', id: '', newFile: false },
{ path: 'src/content/de/index.md', id: '', newFile: false },
{ path: 'src/content/fr/index.md', id: 'id', newFile: false },
]);
});
it('should add missing locale files to diff files when structure is MULTIPLE_FILES', () => {
expect(
i18n.getI18nDataFiles(
fromJS({
i18n: { structure: i18n.I18N_STRUCTURE.MULTIPLE_FILES, locales, default_locale },
}),
...args,
[{ path: 'src/content/index.fr.md', id: 'id', newFile: false }],
),
).toEqual([
{ path: 'src/content/index.en.md', id: '', newFile: false },
{ path: 'src/content/index.de.md', id: '', newFile: false },
{ path: 'src/content/index.fr.md', id: 'id', newFile: false },
]);
});
it('should return a single file when structure is SINGLE_FILE', () => {
expect(
i18n.getI18nDataFiles(
fromJS({
i18n: { structure: i18n.I18N_STRUCTURE.SINGLE_FILE, locales, default_locale },
}),
...args,
[{ path: 'src/content/index.md', id: 'id', newFile: false }],
),
).toEqual([{ path: 'src/content/index.md', id: 'id', newFile: false }]);
});
});
describe('getI18nBackup', () => {
it('should return i18n with raw data', () => {
const locales = ['en', 'de', 'fr'];
const default_locale = 'en';
expect(
i18n.getI18nBackup(
fromJS({
i18n: { structure: i18n.I18N_STRUCTURE.MULTIPLE_FILES, locales, default_locale },
}),
fromJS({
data: 'raw_en',
i18n: {
de: { data: 'raw_de' },
fr: { data: 'raw_fr' },
},
}),
e => e.get('data'),
),
).toEqual({ de: { raw: 'raw_de' }, fr: { raw: 'raw_fr' } });
});
});
describe('formatI18nBackup', () => {
it('should return i18n with formatted data', () => {
expect(
i18n.formatI18nBackup({ de: { raw: 'raw_de' }, fr: { raw: 'raw_fr' } }, raw => ({
data: raw,
})),
).toEqual({ de: { data: 'raw_de' }, fr: { data: 'raw_fr' } });
});
});
describe('duplicateI18nFields', () => {
it('should duplicate non nested field when field i18n is DUPLICATE', () => {
const date = new Date('2020/01/01');
expect(
i18n
.duplicateI18nFields(
fromJS({ entry: { data: { date } } }),
fromJS({ name: 'date', i18n: i18n.I18N_FIELD.DUPLICATE }),
['en', 'de', 'fr'],
'en',
)
.toJS(),
).toEqual({
entry: {
data: { date },
i18n: {
de: { data: { date } },
fr: { data: { date } },
},
},
});
});
it('should not duplicate field when field i18n is not DUPLICATE', () => {
const date = new Date('2020/01/01');
[i18n.I18N_FIELD.TRANSLATE, i18n.I18N_FIELD.TRANSLATE.DUPLICATE].forEach(fieldI18n => {
expect(
i18n
.duplicateI18nFields(
fromJS({ entry: { data: { date } } }),
fromJS({ name: 'date', i18n: fieldI18n }),
['en', 'de', 'fr'],
'en',
)
.toJS(),
).toEqual({
entry: {
data: { date },
},
});
});
});
it('should duplicate nested field when nested fields i18n is DUPLICATE', () => {
const date = new Date('2020/01/01');
const value = fromJS({ title: 'title', date, boolean: true });
expect(
i18n
.duplicateI18nFields(
fromJS({ entry: { data: { object: value } } }),
fromJS({
name: 'object',
fields: [
{ name: 'string', i18n: i18n.I18N_FIELD.TRANSLATE },
{ name: 'date', i18n: i18n.I18N_FIELD.DUPLICATE },
{ name: 'boolean', i18n: i18n.I18N_FIELD.NONE },
],
i18n: i18n.I18N_FIELD.TRANSLATE,
}),
['en', 'de', 'fr'],
'en',
)
.toJS(),
).toEqual({
entry: {
data: { object: value.toJS() },
i18n: {
de: { data: { object: { date } } },
fr: { data: { object: { date } } },
},
},
});
});
});
describe('getPreviewEntry', () => {
it('should set data to i18n data when locale is not default', () => {
expect(
i18n
.getPreviewEntry(
fromJS({
data: { title: 'en', body: 'markdown' },
i18n: { de: { data: { title: 'de' } } },
}),
'de',
)
.toJS(),
).toEqual({
data: { title: 'de' },
i18n: { de: { data: { title: 'de' } } },
});
});
it('should not change entry for default locale', () => {
const entry = fromJS({
data: { title: 'en', body: 'markdown' },
i18n: { de: { data: { title: 'de' } } },
});
expect(i18n.getPreviewEntry(entry, 'en', 'en')).toBe(entry);
});
});
});

View File

@ -0,0 +1,407 @@
import { Map, List } from 'immutable';
import { set, trimEnd, groupBy } from 'lodash';
import { Collection, Entry, EntryDraft, EntryField, EntryMap } from '../types/redux';
import { selectEntrySlug } from '../reducers/collections';
import { EntryValue } from '../valueObjects/Entry';
export const I18N = 'i18n';
export enum I18N_STRUCTURE {
MULTIPLE_FOLDERS = 'multiple_folders',
MULTIPLE_FILES = 'multiple_files',
SINGLE_FILE = 'single_file',
}
export enum I18N_FIELD {
TRANSLATE = 'translate',
DUPLICATE = 'duplicate',
NONE = 'none',
}
export const hasI18n = (collection: Collection) => {
return collection.has(I18N);
};
type I18nInfo = {
locales: string[];
defaultLocale: string;
structure: I18N_STRUCTURE;
};
export const getI18nInfo = (collection: Collection) => {
if (!hasI18n(collection)) {
return {};
}
const { structure, locales, default_locale: defaultLocale } = collection.get(I18N).toJS();
return { structure, locales, defaultLocale } as I18nInfo;
};
export const getI18nFilesDepth = (collection: Collection, depth: number) => {
const { structure } = getI18nInfo(collection) as I18nInfo;
if (structure === I18N_STRUCTURE.MULTIPLE_FOLDERS) {
return depth + 1;
}
return depth;
};
export const isFieldTranslatable = (field: EntryField, locale: string, defaultLocale: string) => {
const isTranslatable = locale !== defaultLocale && field.get(I18N) === I18N_FIELD.TRANSLATE;
return isTranslatable;
};
export const isFieldDuplicate = (field: EntryField, locale: string, defaultLocale: string) => {
const isDuplicate = locale !== defaultLocale && field.get(I18N) === I18N_FIELD.DUPLICATE;
return isDuplicate;
};
export const isFieldHidden = (field: EntryField, locale: string, defaultLocale: string) => {
const isHidden = locale !== defaultLocale && field.get(I18N) === I18N_FIELD.NONE;
return isHidden;
};
export const getLocaleDataPath = (locale: string) => {
return [I18N, locale, 'data'];
};
export const getDataPath = (locale: string, defaultLocale: string) => {
const dataPath = locale !== defaultLocale ? getLocaleDataPath(locale) : ['data'];
return dataPath;
};
export const getFilePath = (
structure: I18N_STRUCTURE,
extension: string,
path: string,
slug: string,
locale: string,
) => {
switch (structure) {
case I18N_STRUCTURE.MULTIPLE_FOLDERS:
return path.replace(`/${slug}`, `/${locale}/${slug}`);
case I18N_STRUCTURE.MULTIPLE_FILES:
return path.replace(extension, `${locale}.${extension}`);
case I18N_STRUCTURE.SINGLE_FILE:
default:
return path;
}
};
export const getLocaleFromPath = (structure: I18N_STRUCTURE, extension: string, path: string) => {
switch (structure) {
case I18N_STRUCTURE.MULTIPLE_FOLDERS: {
const parts = path.split('/');
// filename
parts.pop();
// locale
return parts.pop();
}
case I18N_STRUCTURE.MULTIPLE_FILES: {
const parts = trimEnd(path, `.${extension}`);
return parts.split('.').pop();
}
case I18N_STRUCTURE.SINGLE_FILE:
default:
return '';
}
};
export const getFilePaths = (
collection: Collection,
extension: string,
path: string,
slug: string,
) => {
const { structure, locales } = getI18nInfo(collection) as I18nInfo;
const paths = locales.map(locale =>
getFilePath(structure as I18N_STRUCTURE, extension, path, slug, locale),
);
return paths;
};
export const normalizeFilePath = (structure: I18N_STRUCTURE, path: string, locale: string) => {
switch (structure) {
case I18N_STRUCTURE.MULTIPLE_FOLDERS:
return path.replace(`${locale}/`, '');
case I18N_STRUCTURE.MULTIPLE_FILES:
return path.replace(`.${locale}`, '');
case I18N_STRUCTURE.SINGLE_FILE:
default:
return path;
}
};
export const getI18nFiles = (
collection: Collection,
extension: string,
entryDraft: EntryMap,
entryToRaw: (entryDraft: EntryMap) => string,
path: string,
slug: string,
newPath?: string,
) => {
const { structure, defaultLocale, locales } = getI18nInfo(collection) as I18nInfo;
if (structure === I18N_STRUCTURE.SINGLE_FILE) {
const data = locales.reduce((map, locale) => {
const dataPath = getDataPath(locale, defaultLocale);
return map.set(locale, entryDraft.getIn(dataPath));
}, Map<string, unknown>({}));
const draft = entryDraft.set('data', data);
return [
{
path: getFilePath(structure, extension, path, slug, locales[0]),
slug,
raw: entryToRaw(draft),
...(newPath && {
newPath: getFilePath(structure, extension, newPath, slug, locales[0]),
}),
},
];
}
const dataFiles = locales
.map(locale => {
const dataPath = getDataPath(locale, defaultLocale);
const draft = entryDraft.set('data', entryDraft.getIn(dataPath));
return {
path: getFilePath(structure, extension, path, slug, locale),
slug,
raw: draft.get('data') ? entryToRaw(draft) : '',
...(newPath && {
newPath: getFilePath(structure, extension, newPath, slug, locale),
}),
};
})
.filter(dataFile => dataFile.raw);
return dataFiles;
};
export const getI18nBackup = (
collection: Collection,
entry: EntryMap,
entryToRaw: (entry: EntryMap) => string,
) => {
const { locales, defaultLocale } = getI18nInfo(collection) as I18nInfo;
const i18nBackup = locales
.filter(l => l !== defaultLocale)
.reduce((acc, locale) => {
const dataPath = getDataPath(locale, defaultLocale);
const data = entry.getIn(dataPath);
if (!data) {
return acc;
}
const draft = entry.set('data', data);
return { ...acc, [locale]: { raw: entryToRaw(draft) } };
}, {} as Record<string, { raw: string }>);
return i18nBackup;
};
export const formatI18nBackup = (
i18nBackup: Record<string, { raw: string }>,
formatRawData: (raw: string) => EntryValue,
) => {
const i18n = Object.entries(i18nBackup).reduce((acc, [locale, { raw }]) => {
const entry = formatRawData(raw);
return { ...acc, [locale]: { data: entry.data } };
}, {});
return i18n;
};
const mergeValues = (
collection: Collection,
structure: I18N_STRUCTURE,
defaultLocale: string,
values: { locale: string; value: EntryValue }[],
) => {
let defaultEntry = values.find(e => e.locale === defaultLocale);
if (!defaultEntry) {
defaultEntry = values[0];
console.warn(`Could not locale entry for default locale '${defaultLocale}'`);
}
const i18n = values
.filter(e => e.locale !== defaultEntry!.locale)
.reduce((acc, { locale, value }) => {
const dataPath = getLocaleDataPath(locale);
return set(acc, dataPath, value.data);
}, {});
const path = normalizeFilePath(structure, defaultEntry.value.path, defaultLocale);
const slug = selectEntrySlug(collection, path) as string;
const entryValue: EntryValue = {
...defaultEntry.value,
raw: '',
...i18n,
path,
slug,
};
return entryValue;
};
const mergeSingleFileValue = (entryValue: EntryValue, defaultLocale: string, locales: string[]) => {
const data = entryValue.data[defaultLocale];
const i18n = locales
.filter(l => l !== defaultLocale)
.map(l => ({ locale: l, value: entryValue.data[l] }))
.filter(e => e.value)
.reduce((acc, e) => {
return { ...acc, [e.locale]: { data: e.value } };
}, {});
return {
...entryValue,
data,
i18n,
raw: '',
};
};
export const getI18nEntry = async (
collection: Collection,
extension: string,
path: string,
slug: string,
getEntryValue: (path: string) => Promise<EntryValue>,
) => {
const { structure, locales, defaultLocale } = getI18nInfo(collection) as I18nInfo;
let entryValue: EntryValue;
if (structure === I18N_STRUCTURE.SINGLE_FILE) {
entryValue = mergeSingleFileValue(await getEntryValue(path), defaultLocale, locales);
} else {
const entryValues = await Promise.all(
locales.map(async locale => {
const entryPath = getFilePath(structure, extension, path, slug, locale);
const value = await getEntryValue(entryPath).catch(() => null);
return { value, locale };
}),
);
const nonNullValues = entryValues.filter(e => e.value !== null) as {
value: EntryValue;
locale: string;
}[];
entryValue = mergeValues(collection, structure, defaultLocale, nonNullValues);
}
return entryValue;
};
export const groupEntries = (collection: Collection, extension: string, entries: EntryValue[]) => {
const { structure, defaultLocale, locales } = getI18nInfo(collection) as I18nInfo;
if (structure === I18N_STRUCTURE.SINGLE_FILE) {
return entries.map(e => mergeSingleFileValue(e, defaultLocale, locales));
}
const grouped = groupBy(
entries.map(e => ({
locale: getLocaleFromPath(structure, extension, e.path) as string,
value: e,
})),
({ locale, value: e }) => {
return normalizeFilePath(structure, e.path, locale);
},
);
const groupedEntries = Object.values(grouped).reduce((acc, values) => {
const entryValue = mergeValues(collection, structure, defaultLocale, values);
return [...acc, entryValue];
}, [] as EntryValue[]);
return groupedEntries;
};
export const getI18nDataFiles = (
collection: Collection,
extension: string,
path: string,
slug: string,
diffFiles: { path: string; id: string; newFile: boolean }[],
) => {
const { structure } = getI18nInfo(collection) as I18nInfo;
if (structure === I18N_STRUCTURE.SINGLE_FILE) {
return diffFiles;
}
const paths = getFilePaths(collection, extension, path, slug);
const dataFiles = paths.reduce((acc, path) => {
const dataFile = diffFiles.find(file => file.path === path);
if (dataFile) {
return [...acc, dataFile];
} else {
return [...acc, { path, id: '', newFile: false }];
}
}, [] as { path: string; id: string; newFile: boolean }[]);
return dataFiles;
};
export const duplicateI18nFields = (
entryDraft: EntryDraft,
field: EntryField,
locales: string[],
defaultLocale: string,
fieldPath: string[] = [field.get('name')],
) => {
const value = entryDraft.getIn(['entry', 'data', ...fieldPath]);
if (field.get(I18N) === I18N_FIELD.DUPLICATE) {
locales
.filter(l => l !== defaultLocale)
.forEach(l => {
entryDraft = entryDraft.setIn(
['entry', ...getDataPath(l, defaultLocale), ...fieldPath],
value,
);
});
}
if (field.has('field') && !List.isList(value)) {
const fields = [field.get('field') as EntryField];
fields.forEach(field => {
entryDraft = duplicateI18nFields(entryDraft, field, locales, defaultLocale, [
...fieldPath,
field.get('name'),
]);
});
} else if (field.has('fields') && !List.isList(value)) {
const fields = field.get('fields')!.toArray() as EntryField[];
fields.forEach(field => {
entryDraft = duplicateI18nFields(entryDraft, field, locales, defaultLocale, [
...fieldPath,
field.get('name'),
]);
});
}
return entryDraft;
};
export const getPreviewEntry = (entry: EntryMap, locale: string, defaultLocale: string) => {
if (locale === defaultLocale) {
return entry;
}
return entry.set('data', entry.getIn([I18N, locale, 'data']));
};
export const serializeI18n = (
collection: Collection,
entry: Entry,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serializeValues: (data: any) => any,
) => {
const { locales, defaultLocale } = getI18nInfo(collection) as I18nInfo;
locales
.filter(locale => locale !== defaultLocale)
.forEach(locale => {
const dataPath = getLocaleDataPath(locale);
entry = entry.setIn(dataPath, serializeValues(entry.getIn(dataPath)));
});
return entry;
};

View File

@ -25,6 +25,7 @@ import {
import { get } from 'lodash';
import { selectFolderEntryExtension, selectHasMetaPath } from './collections';
import { join } from 'path';
import { getDataPath, duplicateI18nFields } from '../lib/i18n';
const initialState = Map({
entry: Map(),
@ -90,20 +91,25 @@ const entryDraftReducer = (state = Map(), action) => {
}
case DRAFT_CHANGE_FIELD: {
return state.withMutations(state => {
const { field, value, metadata, entries } = action.payload;
const { field, value, metadata, entries, i18n } = action.payload;
const name = field.get('name');
const meta = field.get('meta');
const dataPath = (i18n && getDataPath(i18n.currentLocale, i18n.defaultLocale)) || ['data'];
if (meta) {
state.setIn(['entry', 'meta', name], value);
} else {
state.setIn(['entry', 'data', name], value);
state.setIn(['entry', ...dataPath, name], value);
if (i18n) {
state = duplicateI18nFields(state, field, i18n.locales, i18n.defaultLocale);
}
}
state.mergeDeepIn(['fieldsMetaData'], fromJS(metadata));
const newData = state.getIn(['entry', 'data']);
const newData = state.getIn(['entry', ...dataPath]);
const newMeta = state.getIn(['entry', 'meta']);
state.set(
'hasChanged',
!entries.some(e => newData.equals(e.get('data'))) ||
!entries.some(e => newData.equals(e.get(...dataPath))) ||
!entries.some(e => newMeta.equals(e.get('meta'))),
);
});

View File

@ -16,10 +16,12 @@ export interface StaticallyTypedRecord<T> {
keys: [K1, K2, K3],
defaultValue?: V,
): T[K1][K2][K3];
getIn(keys: string[]): unknown;
setIn<K1 extends keyof T, K2 extends keyof T[K1], V extends T[K1][K2]>(
keys: [K1, K2],
value: V,
): StaticallyTypedRecord<T>;
setIn(keys: string[], value: unknown): StaticallyTypedRecord<T> & T;
toJS(): T;
isEmpty(): boolean;
some<K extends keyof T>(predicate: (value: T[K], key: K, iter: this) => boolean): boolean;

View File

@ -123,6 +123,7 @@ export type EntryField = StaticallyTypedRecord<{
public_folder?: string;
comment?: string;
meta?: boolean;
i18n: 'translate' | 'duplicate' | 'none';
}>;
export type EntryFields = List<EntryField>;
@ -161,6 +162,12 @@ type MetaObject = {
type Meta = StaticallyTypedRecord<MetaObject>;
type i18n = StaticallyTypedRecord<{
structure: string;
locales: string[];
default_locale: string;
}>;
type CollectionObject = {
name: string;
folder?: string;
@ -187,6 +194,7 @@ type CollectionObject = {
view_filters: List<StaticallyTypedRecord<ViewFilter>>;
nested?: Nested;
meta?: Meta;
i18n: i18n;
};
export type Collection = StaticallyTypedRecord<CollectionObject>;

View File

@ -30,6 +30,10 @@ export interface EntryValue {
updatedOn: string;
status?: string;
meta: { path?: string };
i18n?: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[locale: string]: any;
};
}
export function createEntry(collection: string, slug = '', path = '', options: Options = {}) {