Feat: multi content authoring (#4139)
This commit is contained in:
@ -9,7 +9,6 @@ import {
|
||||
APIError,
|
||||
ApiRequest,
|
||||
AssetProxy,
|
||||
Entry,
|
||||
PersistOptions,
|
||||
readFile,
|
||||
CMS_BRANCH_PREFIX,
|
||||
@ -27,6 +26,7 @@ import {
|
||||
requestWithBackoff,
|
||||
readFileMetadata,
|
||||
throwOnConflictingBranches,
|
||||
DataFile,
|
||||
} from 'netlify-cms-lib-util';
|
||||
import { dirname } from 'path';
|
||||
import { oneLine } from 'common-tags';
|
||||
@ -437,11 +437,11 @@ export default class API {
|
||||
// delete the file
|
||||
formData.append('files', file.path);
|
||||
} else if (file.newPath) {
|
||||
const contentBlob = get(file, 'fileObj', new Blob([(file as Entry).raw]));
|
||||
const contentBlob = get(file, 'fileObj', new Blob([(file as DataFile).raw]));
|
||||
toMove.push({ from: file.path, to: file.newPath, contentBlob });
|
||||
} else {
|
||||
// add/modify the file
|
||||
const contentBlob = get(file, 'fileObj', new Blob([(file as Entry).raw]));
|
||||
const contentBlob = get(file, 'fileObj', new Blob([(file as DataFile).raw]));
|
||||
// Third param is filename header, in case path is `message`, `branch`, etc.
|
||||
formData.append(file.path, contentBlob, basename(file.path));
|
||||
}
|
||||
@ -502,10 +502,11 @@ export default class API {
|
||||
return files;
|
||||
}
|
||||
|
||||
async persistFiles(entry: Entry | null, mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
const files = entry ? [entry, ...mediaFiles] : mediaFiles;
|
||||
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
const files = [...dataFiles, ...mediaFiles];
|
||||
if (options.useWorkflow) {
|
||||
return this.editorialWorkflowGit(files, entry as Entry, options);
|
||||
const slug = dataFiles[0].slug;
|
||||
return this.editorialWorkflowGit(files, slug, options);
|
||||
} else {
|
||||
return this.uploadFiles(files, { commitMessage: options.commitMessage, branch: this.branch });
|
||||
}
|
||||
@ -587,8 +588,12 @@ export default class API {
|
||||
return diffs;
|
||||
}
|
||||
|
||||
async editorialWorkflowGit(files: (Entry | AssetProxy)[], entry: Entry, options: PersistOptions) {
|
||||
const contentKey = generateContentKey(options.collectionName as string, entry.slug);
|
||||
async editorialWorkflowGit(
|
||||
files: (DataFile | AssetProxy)[],
|
||||
slug: string,
|
||||
options: PersistOptions,
|
||||
) {
|
||||
const contentKey = generateContentKey(options.collectionName as string, slug);
|
||||
const branch = branchFromContentKey(contentKey);
|
||||
const unpublished = options.unpublished || false;
|
||||
if (!unpublished) {
|
||||
@ -620,9 +625,11 @@ export default class API {
|
||||
}
|
||||
}
|
||||
|
||||
deleteFile = (path: string, message: string) => {
|
||||
deleteFiles = (paths: string[], message: string) => {
|
||||
const body = new FormData();
|
||||
body.append('files', path);
|
||||
paths.forEach(path => {
|
||||
body.append('files', path);
|
||||
});
|
||||
body.append('branch', this.branch);
|
||||
if (message) {
|
||||
body.append('message', message);
|
||||
|
@ -432,15 +432,17 @@ export default class BitbucketBackend implements Implementation {
|
||||
};
|
||||
}
|
||||
|
||||
async persistEntry(entry: Entry, mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
async persistEntry(entry: Entry, options: PersistOptions) {
|
||||
const client = await this.getLargeMediaClient();
|
||||
// persistEntry is a transactional operation
|
||||
return runWithLock(
|
||||
this.lock,
|
||||
async () =>
|
||||
this.api!.persistFiles(
|
||||
entry,
|
||||
client.enabled ? await getLargeMediaFilteredMediaFiles(client, mediaFiles) : mediaFiles,
|
||||
entry.dataFiles,
|
||||
client.enabled
|
||||
? await getLargeMediaFilteredMediaFiles(client, entry.assets)
|
||||
: entry.assets,
|
||||
options,
|
||||
),
|
||||
'Failed to acquire persist entry lock',
|
||||
@ -468,7 +470,7 @@ export default class BitbucketBackend implements Implementation {
|
||||
|
||||
const [id] = await Promise.all([
|
||||
getBlobSHA(fileObj),
|
||||
this.api!.persistFiles(null, [mediaFile], options),
|
||||
this.api!.persistFiles([], [mediaFile], options),
|
||||
]);
|
||||
|
||||
const url = URL.createObjectURL(fileObj);
|
||||
@ -484,8 +486,8 @@ export default class BitbucketBackend implements Implementation {
|
||||
};
|
||||
}
|
||||
|
||||
deleteFile(path: string, commitMessage: string) {
|
||||
return this.api!.deleteFile(path, commitMessage);
|
||||
deleteFiles(paths: string[], commitMessage: string) {
|
||||
return this.api!.deleteFiles(paths, commitMessage);
|
||||
}
|
||||
|
||||
traverseCursor(cursor: Cursor, action: string) {
|
||||
|
@ -534,13 +534,14 @@ export default class GitGateway implements Implementation {
|
||||
return this.backend!.getMediaFile(path);
|
||||
}
|
||||
|
||||
async persistEntry(entry: Entry, mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
async persistEntry(entry: Entry, options: PersistOptions) {
|
||||
const client = await this.getLargeMediaClient();
|
||||
return this.backend!.persistEntry(
|
||||
entry,
|
||||
client.enabled ? await getLargeMediaFilteredMediaFiles(client, mediaFiles) : mediaFiles,
|
||||
options,
|
||||
);
|
||||
if (client.enabled) {
|
||||
const assets = await getLargeMediaFilteredMediaFiles(client, entry.assets);
|
||||
return this.backend!.persistEntry({ ...entry, assets }, options);
|
||||
} else {
|
||||
return this.backend!.persistEntry(entry, options);
|
||||
}
|
||||
}
|
||||
|
||||
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
|
||||
@ -558,8 +559,8 @@ export default class GitGateway implements Implementation {
|
||||
displayURL,
|
||||
};
|
||||
}
|
||||
deleteFile(path: string, commitMessage: string) {
|
||||
return this.backend!.deleteFile(path, commitMessage);
|
||||
deleteFiles(paths: string[], commitMessage: string) {
|
||||
return this.backend!.deleteFiles(paths, commitMessage);
|
||||
}
|
||||
async getDeployPreview(collection: string, slug: string) {
|
||||
let preview = await this.backend!.getDeployPreview(collection, slug);
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
localForage,
|
||||
basename,
|
||||
AssetProxy,
|
||||
Entry as LibEntry,
|
||||
DataFile,
|
||||
PersistOptions,
|
||||
readFileMetadata,
|
||||
CMS_BRANCH_PREFIX,
|
||||
@ -62,10 +62,6 @@ interface TreeFile {
|
||||
raw?: string;
|
||||
}
|
||||
|
||||
export interface Entry extends LibEntry {
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
type Override<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
|
||||
|
||||
type TreeEntry = Override<GitCreateTreeParamsTree, { sha: string | null }>;
|
||||
@ -877,8 +873,8 @@ export default class API {
|
||||
}));
|
||||
}
|
||||
|
||||
async persistFiles(entry: Entry | null, mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
const files = entry ? mediaFiles.concat(entry) : mediaFiles;
|
||||
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
const files = mediaFiles.concat(dataFiles);
|
||||
const uploadPromises = files.map(file => this.uploadBlob(file));
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
@ -896,12 +892,8 @@ export default class API {
|
||||
sha,
|
||||
}),
|
||||
);
|
||||
return this.editorialWorkflowGit(
|
||||
files as TreeFile[],
|
||||
entry as Entry,
|
||||
mediaFilesList,
|
||||
options,
|
||||
);
|
||||
const slug = dataFiles[0].slug;
|
||||
return this.editorialWorkflowGit(files as TreeFile[], slug, mediaFilesList, options);
|
||||
}
|
||||
}
|
||||
|
||||
@ -927,29 +919,16 @@ export default class API {
|
||||
}
|
||||
}
|
||||
|
||||
deleteFile(path: string, message: string) {
|
||||
async deleteFiles(paths: string[], message: string) {
|
||||
if (this.useOpenAuthoring) {
|
||||
return Promise.reject('Cannot delete published entries as an Open Authoring user!');
|
||||
}
|
||||
|
||||
const branch = this.branch;
|
||||
|
||||
return this.getFileSha(path, { branch }).then(sha => {
|
||||
const params: { sha: string; message: string; branch: string; author?: { date: string } } = {
|
||||
sha,
|
||||
message,
|
||||
branch,
|
||||
};
|
||||
const opts = { method: 'DELETE', params };
|
||||
if (this.commitAuthor) {
|
||||
opts.params.author = {
|
||||
...this.commitAuthor,
|
||||
date: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
const fileURL = `${this.repoURL}/contents/${path}`;
|
||||
return this.request(fileURL, opts);
|
||||
});
|
||||
const branchData = await this.getDefaultBranch();
|
||||
const files = paths.map(path => ({ path, sha: null }));
|
||||
const changeTree = await this.updateTree(branchData.commit.sha, files);
|
||||
const commit = await this.commit(message, changeTree);
|
||||
await this.patchBranch(this.branch, commit.sha);
|
||||
}
|
||||
|
||||
async createBranchAndPullRequest(branchName: string, sha: string, commitMessage: string) {
|
||||
@ -966,11 +945,11 @@ export default class API {
|
||||
|
||||
async editorialWorkflowGit(
|
||||
files: TreeFile[],
|
||||
entry: Entry,
|
||||
slug: string,
|
||||
mediaFilesList: MediaFile[],
|
||||
options: PersistOptions,
|
||||
) {
|
||||
const contentKey = this.generateContentKey(options.collectionName as string, entry.slug);
|
||||
const contentKey = this.generateContentKey(options.collectionName as string, slug);
|
||||
const branch = branchFromContentKey(contentKey);
|
||||
const unpublished = options.unpublished || false;
|
||||
if (!unpublished) {
|
||||
|
@ -229,12 +229,17 @@ describe('github API', () => {
|
||||
mockAPI(api, responses);
|
||||
|
||||
const entry = {
|
||||
slug: 'entry',
|
||||
sha: 'abc',
|
||||
path: 'content/posts/new-post.md',
|
||||
raw: 'content',
|
||||
dataFiles: [
|
||||
{
|
||||
slug: 'entry',
|
||||
sha: 'abc',
|
||||
path: 'content/posts/new-post.md',
|
||||
raw: 'content',
|
||||
},
|
||||
],
|
||||
assets: [],
|
||||
};
|
||||
await api.persistFiles(entry, [], { commitMessage: 'commitMessage' });
|
||||
await api.persistFiles(entry.dataFiles, entry.assets, { commitMessage: 'commitMessage' });
|
||||
|
||||
expect(api.request).toHaveBeenCalledTimes(5);
|
||||
|
||||
@ -242,7 +247,10 @@ describe('github API', () => {
|
||||
'/repos/owner/repo/git/blobs',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content: Base64.encode(entry.raw), encoding: 'base64' }),
|
||||
body: JSON.stringify({
|
||||
content: Base64.encode(entry.dataFiles[0].raw),
|
||||
encoding: 'base64',
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
@ -297,35 +305,38 @@ describe('github API', () => {
|
||||
api.editorialWorkflowGit = jest.fn();
|
||||
|
||||
const entry = {
|
||||
slug: 'entry',
|
||||
sha: 'abc',
|
||||
path: 'content/posts/new-post.md',
|
||||
raw: 'content',
|
||||
dataFiles: [
|
||||
{
|
||||
slug: 'entry',
|
||||
sha: 'abc',
|
||||
path: 'content/posts/new-post.md',
|
||||
raw: 'content',
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
path: '/static/media/image-1.png',
|
||||
sha: 'image-1.png',
|
||||
},
|
||||
{
|
||||
path: '/static/media/image-2.png',
|
||||
sha: 'image-2.png',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mediaFiles = [
|
||||
{
|
||||
path: '/static/media/image-1.png',
|
||||
sha: 'image-1.png',
|
||||
},
|
||||
{
|
||||
path: '/static/media/image-2.png',
|
||||
sha: 'image-2.png',
|
||||
},
|
||||
];
|
||||
|
||||
await api.persistFiles(entry, mediaFiles, { useWorkflow: true });
|
||||
await api.persistFiles(entry.dataFiles, entry.assets, { useWorkflow: true });
|
||||
|
||||
expect(api.uploadBlob).toHaveBeenCalledTimes(3);
|
||||
expect(api.uploadBlob).toHaveBeenCalledWith(entry);
|
||||
expect(api.uploadBlob).toHaveBeenCalledWith(mediaFiles[0]);
|
||||
expect(api.uploadBlob).toHaveBeenCalledWith(mediaFiles[1]);
|
||||
expect(api.uploadBlob).toHaveBeenCalledWith(entry.dataFiles[0]);
|
||||
expect(api.uploadBlob).toHaveBeenCalledWith(entry.assets[0]);
|
||||
expect(api.uploadBlob).toHaveBeenCalledWith(entry.assets[1]);
|
||||
|
||||
expect(api.editorialWorkflowGit).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(api.editorialWorkflowGit).toHaveBeenCalledWith(
|
||||
mediaFiles.concat(entry),
|
||||
entry,
|
||||
entry.assets.concat(entry.dataFiles),
|
||||
entry.dataFiles[0].slug,
|
||||
[
|
||||
{ path: 'static/media/image-1.png', sha: 'image-1.png' },
|
||||
{ path: 'static/media/image-2.png', sha: 'image-2.png' },
|
||||
|
@ -104,7 +104,7 @@ describe('github backend implementation', () => {
|
||||
});
|
||||
|
||||
expect(persistFiles).toHaveBeenCalledTimes(1);
|
||||
expect(persistFiles).toHaveBeenCalledWith(null, [mediaFile], {});
|
||||
expect(persistFiles).toHaveBeenCalledWith([], [mediaFile], {});
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
||||
expect(createObjectURL).toHaveBeenCalledWith(mediaFile.fileObj);
|
||||
});
|
||||
|
@ -30,10 +30,11 @@ import {
|
||||
contentKeyFromBranch,
|
||||
unsentRequest,
|
||||
branchFromContentKey,
|
||||
Entry,
|
||||
} from 'netlify-cms-lib-util';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import API, { Entry, API_NAME } from './API';
|
||||
import API, { API_NAME } from './API';
|
||||
import GraphQLAPI from './GraphQLAPI';
|
||||
|
||||
type GitHubUser = Octokit.UsersGetAuthenticatedResponse;
|
||||
@ -473,18 +474,18 @@ export default class GitHub implements Implementation {
|
||||
);
|
||||
}
|
||||
|
||||
persistEntry(entry: Entry, mediaFiles: AssetProxy[] = [], options: PersistOptions) {
|
||||
persistEntry(entry: Entry, options: PersistOptions) {
|
||||
// persistEntry is a transactional operation
|
||||
return runWithLock(
|
||||
this.lock,
|
||||
() => this.api!.persistFiles(entry, mediaFiles, options),
|
||||
() => this.api!.persistFiles(entry.dataFiles, entry.assets, options),
|
||||
'Failed to acquire persist entry lock',
|
||||
);
|
||||
}
|
||||
|
||||
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
|
||||
try {
|
||||
await this.api!.persistFiles(null, [mediaFile], options);
|
||||
await this.api!.persistFiles([], [mediaFile], options);
|
||||
const { sha, path, fileObj } = mediaFile as AssetProxy & { sha: string };
|
||||
const displayURL = URL.createObjectURL(fileObj);
|
||||
return {
|
||||
@ -500,8 +501,8 @@ export default class GitHub implements Implementation {
|
||||
}
|
||||
}
|
||||
|
||||
deleteFile(path: string, commitMessage: string) {
|
||||
return this.api!.deleteFile(path, commitMessage);
|
||||
deleteFiles(paths: string[], commitMessage: string) {
|
||||
return this.api!.deleteFiles(paths, commitMessage);
|
||||
}
|
||||
|
||||
async traverseCursor(cursor: Cursor, action: string) {
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
APIError,
|
||||
Cursor,
|
||||
ApiRequest,
|
||||
Entry,
|
||||
DataFile,
|
||||
AssetProxy,
|
||||
PersistOptions,
|
||||
readFile,
|
||||
@ -473,7 +473,7 @@ export default class API {
|
||||
const items: CommitItem[] = await Promise.all(
|
||||
files.map(async file => {
|
||||
const [base64Content, fileExists] = await Promise.all([
|
||||
result(file, 'toBase64', partial(this.toBase64, (file as Entry).raw)),
|
||||
result(file, 'toBase64', partial(this.toBase64, (file as DataFile).raw)),
|
||||
this.isFileExists(file.path, branch),
|
||||
]);
|
||||
|
||||
@ -515,10 +515,11 @@ export default class API {
|
||||
return items;
|
||||
}
|
||||
|
||||
async persistFiles(entry: Entry | null, mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
const files = entry ? [entry, ...mediaFiles] : mediaFiles;
|
||||
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
const files = [...dataFiles, ...mediaFiles];
|
||||
if (options.useWorkflow) {
|
||||
return this.editorialWorkflowGit(files, entry as Entry, options);
|
||||
const slug = dataFiles[0].slug;
|
||||
return this.editorialWorkflowGit(files, slug, options);
|
||||
} else {
|
||||
const items = await this.getCommitItems(files, this.branch);
|
||||
return this.uploadAndCommit(items, {
|
||||
@ -527,7 +528,7 @@ export default class API {
|
||||
}
|
||||
}
|
||||
|
||||
deleteFile = (path: string, commitMessage: string) => {
|
||||
deleteFiles = (paths: string[], commitMessage: string) => {
|
||||
const branch = this.branch;
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
const commitParams: CommitsParams = { commit_message: commitMessage, branch };
|
||||
@ -538,12 +539,11 @@ export default class API {
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
commitParams.author_email = email;
|
||||
}
|
||||
return flow([
|
||||
unsentRequest.withMethod('DELETE'),
|
||||
// TODO: only send author params if they are defined.
|
||||
unsentRequest.withParams(commitParams),
|
||||
this.request,
|
||||
])(`${this.repoURL}/repository/files/${encodeURIComponent(path)}`);
|
||||
|
||||
const items = paths.map(path => ({ path, action: CommitAction.DELETE }));
|
||||
return this.uploadAndCommit(items, {
|
||||
commitMessage,
|
||||
});
|
||||
};
|
||||
|
||||
async getMergeRequests(sourceBranch?: string) {
|
||||
@ -723,8 +723,12 @@ export default class API {
|
||||
});
|
||||
}
|
||||
|
||||
async editorialWorkflowGit(files: (Entry | AssetProxy)[], entry: Entry, options: PersistOptions) {
|
||||
const contentKey = generateContentKey(options.collectionName as string, entry.slug);
|
||||
async editorialWorkflowGit(
|
||||
files: (DataFile | AssetProxy)[],
|
||||
slug: string,
|
||||
options: PersistOptions,
|
||||
) {
|
||||
const contentKey = generateContentKey(options.collectionName as string, slug);
|
||||
const branch = branchFromContentKey(contentKey);
|
||||
const unpublished = options.unpublished || false;
|
||||
if (!unpublished) {
|
||||
|
@ -263,11 +263,11 @@ export default class GitLab implements Implementation {
|
||||
};
|
||||
}
|
||||
|
||||
async persistEntry(entry: Entry, mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
async persistEntry(entry: Entry, options: PersistOptions) {
|
||||
// persistEntry is a transactional operation
|
||||
return runWithLock(
|
||||
this.lock,
|
||||
() => this.api!.persistFiles(entry, mediaFiles, options),
|
||||
() => this.api!.persistFiles(entry.dataFiles, entry.assets, options),
|
||||
'Failed to acquire persist entry lock',
|
||||
);
|
||||
}
|
||||
@ -277,7 +277,7 @@ export default class GitLab implements Implementation {
|
||||
|
||||
const [id] = await Promise.all([
|
||||
getBlobSHA(fileObj),
|
||||
this.api!.persistFiles(null, [mediaFile], options),
|
||||
this.api!.persistFiles([], [mediaFile], options),
|
||||
]);
|
||||
|
||||
const { path } = mediaFile;
|
||||
@ -294,8 +294,8 @@ export default class GitLab implements Implementation {
|
||||
};
|
||||
}
|
||||
|
||||
deleteFile(path: string, commitMessage: string) {
|
||||
return this.api!.deleteFile(path, commitMessage);
|
||||
deleteFiles(paths: string[], commitMessage: string) {
|
||||
return this.api!.deleteFiles(paths, commitMessage);
|
||||
}
|
||||
|
||||
traverseCursor(cursor: Cursor, action: string) {
|
||||
|
@ -183,13 +183,13 @@ export default class ProxyBackend implements Implementation {
|
||||
});
|
||||
}
|
||||
|
||||
async persistEntry(entry: Entry, assetProxies: AssetProxy[], options: PersistOptions) {
|
||||
const assets = await Promise.all(assetProxies.map(serializeAsset));
|
||||
async persistEntry(entry: Entry, options: PersistOptions) {
|
||||
const assets = await Promise.all(entry.assets.map(serializeAsset));
|
||||
return this.request({
|
||||
action: 'persistEntry',
|
||||
params: {
|
||||
branch: this.branch,
|
||||
entry,
|
||||
dataFiles: entry.dataFiles,
|
||||
assets,
|
||||
options: { ...options, status: options.status || this.options.initialWorkflowStatus },
|
||||
cmsLabelPrefix: this.cmsLabelPrefix,
|
||||
@ -244,10 +244,10 @@ export default class ProxyBackend implements Implementation {
|
||||
return deserializeMediaFile(file);
|
||||
}
|
||||
|
||||
deleteFile(path: string, commitMessage: string) {
|
||||
deleteFiles(paths: string[], commitMessage: string) {
|
||||
return this.request({
|
||||
action: 'deleteFile',
|
||||
params: { branch: this.branch, path, options: { commitMessage } },
|
||||
action: 'deleteFiles',
|
||||
params: { branch: this.branch, paths, options: { commitMessage } },
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -51,8 +51,11 @@ describe('test backend implementation', () => {
|
||||
|
||||
const backend = new TestBackend({});
|
||||
|
||||
const entry = { path: 'posts/some-post.md', raw: 'content', slug: 'some-post.md' };
|
||||
await backend.persistEntry(entry, [], { newEntry: true });
|
||||
const entry = {
|
||||
dataFiles: [{ path: 'posts/some-post.md', raw: 'content', slug: 'some-post.md' }],
|
||||
assets: [],
|
||||
};
|
||||
await backend.persistEntry(entry, { newEntry: true });
|
||||
|
||||
expect(window.repoFiles).toEqual({
|
||||
posts: {
|
||||
@ -80,8 +83,11 @@ describe('test backend implementation', () => {
|
||||
|
||||
const backend = new TestBackend({});
|
||||
|
||||
const entry = { path: 'posts/new-post.md', raw: 'content', slug: 'new-post.md' };
|
||||
await backend.persistEntry(entry, [], { newEntry: true });
|
||||
const entry = {
|
||||
dataFiles: [{ path: 'posts/new-post.md', raw: 'content', slug: 'new-post.md' }],
|
||||
assets: [],
|
||||
};
|
||||
await backend.persistEntry(entry, { newEntry: true });
|
||||
|
||||
expect(window.repoFiles).toEqual({
|
||||
pages: {
|
||||
@ -108,8 +114,8 @@ describe('test backend implementation', () => {
|
||||
|
||||
const slug = 'dir1/dir2/some-post.md';
|
||||
const path = `posts/${slug}`;
|
||||
const entry = { path, raw: 'content', slug };
|
||||
await backend.persistEntry(entry, [], { newEntry: true });
|
||||
const entry = { dataFiles: [{ path, raw: 'content', slug }], assets: [] };
|
||||
await backend.persistEntry(entry, { newEntry: true });
|
||||
|
||||
expect(window.repoFiles).toEqual({
|
||||
posts: {
|
||||
@ -143,8 +149,8 @@ describe('test backend implementation', () => {
|
||||
|
||||
const slug = 'dir1/dir2/some-post.md';
|
||||
const path = `posts/${slug}`;
|
||||
const entry = { path, raw: 'new content', slug };
|
||||
await backend.persistEntry(entry, [], { newEntry: false });
|
||||
const entry = { dataFiles: [{ path, raw: 'new content', slug }], assets: [] };
|
||||
await backend.persistEntry(entry, { newEntry: false });
|
||||
|
||||
expect(window.repoFiles).toEqual({
|
||||
posts: {
|
||||
@ -161,7 +167,7 @@ describe('test backend implementation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
describe('deleteFiles', () => {
|
||||
it('should delete entry by path', async () => {
|
||||
window.repoFiles = {
|
||||
posts: {
|
||||
@ -173,7 +179,7 @@ describe('test backend implementation', () => {
|
||||
|
||||
const backend = new TestBackend({});
|
||||
|
||||
await backend.deleteFile('posts/some-post.md');
|
||||
await backend.deleteFiles(['posts/some-post.md']);
|
||||
expect(window.repoFiles).toEqual({
|
||||
posts: {},
|
||||
});
|
||||
@ -194,7 +200,7 @@ describe('test backend implementation', () => {
|
||||
|
||||
const backend = new TestBackend({});
|
||||
|
||||
await backend.deleteFile('posts/dir1/dir2/some-post.md');
|
||||
await backend.deleteFiles(['posts/dir1/dir2/some-post.md']);
|
||||
expect(window.repoFiles).toEqual({
|
||||
posts: {
|
||||
dir1: {
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
User,
|
||||
Config,
|
||||
ImplementationFile,
|
||||
DataFile,
|
||||
} from 'netlify-cms-lib-util';
|
||||
import { extname, dirname } from 'path';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
@ -20,18 +21,20 @@ import AuthenticationPage from './AuthenticationPage';
|
||||
type RepoFile = { path: string; content: string | AssetProxy };
|
||||
type RepoTree = { [key: string]: RepoFile | RepoTree };
|
||||
|
||||
type Diff = {
|
||||
id: string;
|
||||
originalPath?: string;
|
||||
path: string;
|
||||
newFile: boolean;
|
||||
status: string;
|
||||
content: string | AssetProxy;
|
||||
};
|
||||
|
||||
type UnpublishedRepoEntry = {
|
||||
slug: string;
|
||||
collection: string;
|
||||
status: string;
|
||||
diffs: {
|
||||
id: string;
|
||||
originalPath?: string;
|
||||
path: string;
|
||||
newFile: boolean;
|
||||
status: string;
|
||||
content: string | AssetProxy;
|
||||
}[];
|
||||
diffs: Diff[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@ -257,24 +260,25 @@ export default class TestBackend implements Implementation {
|
||||
|
||||
async addOrUpdateUnpublishedEntry(
|
||||
key: string,
|
||||
path: string,
|
||||
newPath: string | undefined,
|
||||
raw: string,
|
||||
dataFiles: DataFile[],
|
||||
assetProxies: AssetProxy[],
|
||||
slug: string,
|
||||
collection: string,
|
||||
status: string,
|
||||
) {
|
||||
const currentDataFile = window.repoFilesUnpublished[key]?.diffs.find(d => d.path === path);
|
||||
const originalPath = currentDataFile ? currentDataFile.originalPath : path;
|
||||
const diffs = [];
|
||||
diffs.push({
|
||||
originalPath,
|
||||
id: newPath || path,
|
||||
path: newPath || path,
|
||||
newFile: isEmpty(getFile(originalPath as string, window.repoFiles)),
|
||||
status: 'added',
|
||||
content: raw,
|
||||
const diffs: Diff[] = [];
|
||||
dataFiles.forEach(dataFile => {
|
||||
const { path, newPath, raw } = dataFile;
|
||||
const currentDataFile = window.repoFilesUnpublished[key]?.diffs.find(d => d.path === path);
|
||||
const originalPath = currentDataFile ? currentDataFile.originalPath : path;
|
||||
diffs.push({
|
||||
originalPath,
|
||||
id: newPath || path,
|
||||
path: newPath || path,
|
||||
newFile: isEmpty(getFile(originalPath as string, window.repoFiles)),
|
||||
status: 'added',
|
||||
content: raw,
|
||||
});
|
||||
});
|
||||
assetProxies.forEach(a => {
|
||||
const asset = this.normalizeAsset(a);
|
||||
@ -295,22 +299,18 @@ export default class TestBackend implements Implementation {
|
||||
};
|
||||
}
|
||||
|
||||
async persistEntry(
|
||||
{ path, raw, slug, newPath }: Entry,
|
||||
assetProxies: AssetProxy[],
|
||||
options: PersistOptions,
|
||||
) {
|
||||
async persistEntry(entry: Entry, options: PersistOptions) {
|
||||
if (options.useWorkflow) {
|
||||
const slug = entry.dataFiles[0].slug;
|
||||
const key = `${options.collectionName}/${slug}`;
|
||||
const currentEntry = window.repoFilesUnpublished[key];
|
||||
const status =
|
||||
currentEntry?.status || options.status || (this.options.initialWorkflowStatus as string);
|
||||
|
||||
this.addOrUpdateUnpublishedEntry(
|
||||
key,
|
||||
path,
|
||||
newPath,
|
||||
raw,
|
||||
assetProxies,
|
||||
entry.dataFiles,
|
||||
entry.assets,
|
||||
slug,
|
||||
options.collectionName as string,
|
||||
status,
|
||||
@ -318,9 +318,12 @@ export default class TestBackend implements Implementation {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
writeFile(path, raw, window.repoFiles);
|
||||
assetProxies.forEach(a => {
|
||||
writeFile(a.path, raw, window.repoFiles);
|
||||
entry.dataFiles.forEach(dataFile => {
|
||||
const { path, raw } = dataFile;
|
||||
writeFile(path, raw, window.repoFiles);
|
||||
});
|
||||
entry.assets.forEach(a => {
|
||||
writeFile(a.path, a, window.repoFiles);
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
@ -409,8 +412,10 @@ export default class TestBackend implements Implementation {
|
||||
return Promise.resolve(normalizedAsset);
|
||||
}
|
||||
|
||||
deleteFile(path: string) {
|
||||
deleteFile(path, window.repoFiles);
|
||||
deleteFiles(paths: string[]) {
|
||||
paths.forEach(path => {
|
||||
deleteFile(path, window.repoFiles);
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
@ -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', () => {
|
||||
|
@ -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(
|
||||
|
@ -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));
|
||||
|
@ -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);
|
||||
}
|
||||
})
|
||||
|
@ -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) {
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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}>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
|
706
packages/netlify-cms-core/src/lib/__tests__/i18n.spec.js
Normal file
706
packages/netlify-cms-core/src/lib/__tests__/i18n.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
407
packages/netlify-cms-core/src/lib/i18n.ts
Normal file
407
packages/netlify-cms-core/src/lib/i18n.ts
Normal 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;
|
||||
};
|
@ -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'))),
|
||||
);
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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>;
|
||||
|
@ -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 = {}) {
|
||||
|
@ -30,11 +30,17 @@ export interface ImplementationEntry {
|
||||
file: { path: string; label?: string; id?: string | null; author?: string; updatedOn?: string };
|
||||
}
|
||||
|
||||
export interface UnpublishedEntryDiff {
|
||||
id: string;
|
||||
path: string;
|
||||
newFile: boolean;
|
||||
}
|
||||
|
||||
export interface UnpublishedEntry {
|
||||
slug: string;
|
||||
collection: string;
|
||||
status: string;
|
||||
diffs: { id: string; path: string; newFile: boolean }[];
|
||||
diffs: UnpublishedEntryDiff[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@ -45,13 +51,23 @@ export interface Map {
|
||||
set: <T>(key: string, value: T) => Map;
|
||||
}
|
||||
|
||||
export type DataFile = {
|
||||
path: string;
|
||||
slug: string;
|
||||
raw: string;
|
||||
newPath?: string;
|
||||
};
|
||||
|
||||
export type AssetProxy = {
|
||||
path: string;
|
||||
fileObj?: File;
|
||||
toBase64?: () => Promise<string>;
|
||||
};
|
||||
|
||||
export type Entry = { path: string; slug: string; raw: string; newPath?: string };
|
||||
export type Entry = {
|
||||
dataFiles: DataFile[];
|
||||
assets: AssetProxy[];
|
||||
};
|
||||
|
||||
export type PersistOptions = {
|
||||
newEntry?: boolean;
|
||||
@ -116,9 +132,9 @@ export interface Implementation {
|
||||
getMedia: (folder?: string) => Promise<ImplementationMediaFile[]>;
|
||||
getMediaFile: (path: string) => Promise<ImplementationMediaFile>;
|
||||
|
||||
persistEntry: (obj: Entry, assetProxies: AssetProxy[], opts: PersistOptions) => Promise<void>;
|
||||
persistEntry: (entry: Entry, opts: PersistOptions) => Promise<void>;
|
||||
persistMedia: (file: AssetProxy, opts: PersistOptions) => Promise<ImplementationMediaFile>;
|
||||
deleteFile: (path: string, commitMessage: string) => Promise<void>;
|
||||
deleteFiles: (paths: string[], commitMessage: string) => Promise<void>;
|
||||
|
||||
unpublishedEntries: () => Promise<string[]>;
|
||||
unpublishedEntry: (args: {
|
||||
|
@ -20,6 +20,7 @@ import { asyncLock, AsyncLock as AL } from './asyncLock';
|
||||
import {
|
||||
Implementation as I,
|
||||
ImplementationEntry as IE,
|
||||
UnpublishedEntryDiff as UED,
|
||||
UnpublishedEntry as UE,
|
||||
ImplementationMediaFile as IMF,
|
||||
ImplementationFile as IF,
|
||||
@ -40,6 +41,7 @@ import {
|
||||
UnpublishedEntryMediaFile as UEMF,
|
||||
blobToFileObj,
|
||||
allEntriesByFolder,
|
||||
DataFile as DF,
|
||||
} from './implementation';
|
||||
import {
|
||||
readFile,
|
||||
@ -76,6 +78,7 @@ import {
|
||||
export type AsyncLock = AL;
|
||||
export type Implementation = I;
|
||||
export type ImplementationEntry = IE;
|
||||
export type UnpublishedEntryDiff = UED;
|
||||
export type UnpublishedEntry = UE;
|
||||
export type ImplementationMediaFile = IMF;
|
||||
export type ImplementationFile = IF;
|
||||
@ -91,6 +94,7 @@ export type ApiRequest = AR;
|
||||
export type Config = C;
|
||||
export type FetchError = FE;
|
||||
export type PointerFile = PF;
|
||||
export type DataFile = DF;
|
||||
|
||||
export const NetlifyCmsLibUtil = {
|
||||
APIError,
|
||||
|
@ -84,6 +84,9 @@ const en = {
|
||||
invalidPath: `'%{path}' is not a valid path`,
|
||||
pathExists: `Path '%{path}' already exists`,
|
||||
},
|
||||
i18n: {
|
||||
writingInLocale: 'Writing in %{locale}',
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
onLeavePage: 'Are you sure you want to leave this page?',
|
||||
|
@ -23,6 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/joi": "^17.0.2",
|
||||
"async-mutex": "^0.2.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
|
@ -26,7 +26,7 @@ describe('defaultSchema', () => {
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'unknown', params: {} }),
|
||||
'"action" must be one of [info, entriesByFolder, entriesByFiles, getEntry, unpublishedEntries, unpublishedEntry, unpublishedEntryDataFile, unpublishedEntryMediaFile, deleteUnpublishedEntry, persistEntry, updateUnpublishedEntryStatus, publishUnpublishedEntry, getMedia, getMediaFile, persistMedia, deleteFile, getDeployPreview]',
|
||||
'"action" must be one of [info, entriesByFolder, entriesByFiles, getEntry, unpublishedEntries, unpublishedEntry, unpublishedEntryDataFile, unpublishedEntryMediaFile, deleteUnpublishedEntry, persistEntry, updateUnpublishedEntryStatus, publishUnpublishedEntry, getMedia, getMediaFile, persistMedia, deleteFile, deleteFiles, getDeployPreview]',
|
||||
);
|
||||
});
|
||||
|
||||
@ -274,8 +274,19 @@ describe('defaultSchema', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'persistEntry', params: { ...defaultParams } }),
|
||||
'"params.entry" is required',
|
||||
schema.validate({
|
||||
action: 'persistEntry',
|
||||
params: {
|
||||
...defaultParams,
|
||||
assets: [],
|
||||
options: {
|
||||
commitMessage: 'commitMessage',
|
||||
useWorkflow: true,
|
||||
status: 'draft',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'"params" must contain at least one of [entry, dataFiles]',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
@ -309,7 +320,7 @@ describe('defaultSchema', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
it('should pass on valid params (entry argument)', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'persistEntry',
|
||||
@ -327,6 +338,25 @@ describe('defaultSchema', () => {
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should pass on valid params (dataFiles argument)', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'persistEntry',
|
||||
params: {
|
||||
...defaultParams,
|
||||
dataFiles: [{ slug: 'slug', path: 'path', raw: 'content' }],
|
||||
assets: [{ path: 'path', content: 'content', encoding: 'base64' }],
|
||||
options: {
|
||||
commitMessage: 'commitMessage',
|
||||
useWorkflow: true,
|
||||
status: 'draft',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUnpublishedEntryStatus', () => {
|
||||
@ -491,6 +521,31 @@ describe('defaultSchema', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFiles', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'deleteFiles', params: { ...defaultParams } }),
|
||||
'"params.paths" is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'deleteFiles',
|
||||
params: {
|
||||
...defaultParams,
|
||||
paths: ['src/static/images/image.png'],
|
||||
options: { commitMessage: 'commitMessage' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeployPreview', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
@ -18,6 +18,7 @@ const allowedActions = [
|
||||
'getMediaFile',
|
||||
'persistMedia',
|
||||
'deleteFile',
|
||||
'deleteFiles',
|
||||
'getDeployPreview',
|
||||
];
|
||||
|
||||
@ -39,6 +40,13 @@ export const defaultSchema = ({ path = requiredString } = {}) => {
|
||||
encoding: requiredString.valid('base64'),
|
||||
});
|
||||
|
||||
const dataFile = Joi.object({
|
||||
slug: requiredString,
|
||||
path,
|
||||
raw: requiredString,
|
||||
newPath: path.optional(),
|
||||
});
|
||||
|
||||
const params = Joi.when('action', {
|
||||
switch: [
|
||||
{
|
||||
@ -122,12 +130,8 @@ export const defaultSchema = ({ path = requiredString } = {}) => {
|
||||
then: defaultParams
|
||||
.keys({
|
||||
cmsLabelPrefix: Joi.string().optional(),
|
||||
entry: Joi.object({
|
||||
slug: requiredString,
|
||||
path,
|
||||
raw: requiredString,
|
||||
newPath: path.optional(),
|
||||
}).required(),
|
||||
entry: dataFile, // entry is kept for backwards compatibility
|
||||
dataFiles: Joi.array().items(dataFile),
|
||||
assets: Joi.array()
|
||||
.items(asset)
|
||||
.required(),
|
||||
@ -138,6 +142,7 @@ export const defaultSchema = ({ path = requiredString } = {}) => {
|
||||
status: requiredString,
|
||||
}).required(),
|
||||
})
|
||||
.xor('entry', 'dataFiles')
|
||||
.required(),
|
||||
},
|
||||
{
|
||||
@ -198,6 +203,20 @@ export const defaultSchema = ({ path = requiredString } = {}) => {
|
||||
})
|
||||
.required(),
|
||||
},
|
||||
{
|
||||
is: 'deleteFiles',
|
||||
then: defaultParams
|
||||
.keys({
|
||||
paths: Joi.array()
|
||||
.items(path)
|
||||
.min(1)
|
||||
.required(),
|
||||
options: Joi.object({
|
||||
commitMessage: requiredString,
|
||||
}).required(),
|
||||
})
|
||||
.required(),
|
||||
},
|
||||
{
|
||||
is: 'getDeployPreview',
|
||||
then: defaultParams
|
||||
|
@ -12,6 +12,8 @@ import {
|
||||
GetMediaFileParams,
|
||||
PersistMediaParams,
|
||||
DeleteFileParams,
|
||||
DeleteFilesParams,
|
||||
DataFile,
|
||||
} from '../types';
|
||||
import { listRepoFiles, deleteFile, writeFile, move } from '../utils/fs';
|
||||
import { entriesFromFiles, readMediaFile } from '../utils/entries';
|
||||
@ -61,16 +63,27 @@ export const localFsMiddleware = ({ repoPath, logger }: FsOptions) => {
|
||||
break;
|
||||
}
|
||||
case 'persistEntry': {
|
||||
const { entry, assets } = body.params as PersistEntryParams;
|
||||
await writeFile(path.join(repoPath, entry.path), entry.raw);
|
||||
const {
|
||||
entry,
|
||||
dataFiles = [entry as DataFile],
|
||||
assets,
|
||||
} = body.params as PersistEntryParams;
|
||||
await Promise.all(
|
||||
dataFiles.map(dataFile => writeFile(path.join(repoPath, dataFile.path), dataFile.raw)),
|
||||
);
|
||||
// save assets
|
||||
await Promise.all(
|
||||
assets.map(a =>
|
||||
writeFile(path.join(repoPath, a.path), Buffer.from(a.content, a.encoding)),
|
||||
),
|
||||
);
|
||||
if (entry.newPath) {
|
||||
await move(path.join(repoPath, entry.path), path.join(repoPath, entry.newPath));
|
||||
if (dataFiles.every(dataFile => dataFile.newPath)) {
|
||||
dataFiles.forEach(async dataFile => {
|
||||
await move(
|
||||
path.join(repoPath, dataFile.path),
|
||||
path.join(repoPath, dataFile.newPath!),
|
||||
);
|
||||
});
|
||||
}
|
||||
res.json({ message: 'entry persisted' });
|
||||
break;
|
||||
@ -104,6 +117,12 @@ export const localFsMiddleware = ({ repoPath, logger }: FsOptions) => {
|
||||
res.json({ message: `deleted file ${filePath}` });
|
||||
break;
|
||||
}
|
||||
case 'deleteFiles': {
|
||||
const { paths } = body.params as DeleteFilesParams;
|
||||
await Promise.all(paths.map(filePath => deleteFile(repoPath, filePath)));
|
||||
res.json({ message: `deleted files ${paths.join(', ')}` });
|
||||
break;
|
||||
}
|
||||
case 'getDeployPreview': {
|
||||
res.json(null);
|
||||
break;
|
||||
|
@ -26,16 +26,18 @@ import {
|
||||
PersistMediaParams,
|
||||
DeleteFileParams,
|
||||
UpdateUnpublishedEntryStatusParams,
|
||||
Entry,
|
||||
DataFile,
|
||||
GetMediaFileParams,
|
||||
DeleteEntryParams,
|
||||
DeleteFilesParams,
|
||||
UnpublishedEntryDataFileParams,
|
||||
UnpublishedEntryMediaFileParams,
|
||||
} from '../types';
|
||||
// eslint-disable-next-line import/default
|
||||
import simpleGit from 'simple-git/promise';
|
||||
import { Mutex, withTimeout } from 'async-mutex';
|
||||
import { pathTraversal } from '../joi/customValidators';
|
||||
import { listRepoFiles, writeFile, move } from '../utils/fs';
|
||||
import { listRepoFiles, writeFile, move, deleteFile, getUpdateDate } from '../utils/fs';
|
||||
import { entriesFromFiles, readMediaFile } from '../utils/entries';
|
||||
|
||||
const commit = async (git: simpleGit.SimpleGit, commitMessage: string) => {
|
||||
@ -76,18 +78,22 @@ type GitOptions = {
|
||||
const commitEntry = async (
|
||||
git: simpleGit.SimpleGit,
|
||||
repoPath: string,
|
||||
entry: Entry,
|
||||
dataFiles: DataFile[],
|
||||
assets: Asset[],
|
||||
commitMessage: string,
|
||||
) => {
|
||||
// save entry content
|
||||
await writeFile(path.join(repoPath, entry.path), entry.raw);
|
||||
await Promise.all(
|
||||
dataFiles.map(dataFile => writeFile(path.join(repoPath, dataFile.path), dataFile.raw)),
|
||||
);
|
||||
// save assets
|
||||
await Promise.all(
|
||||
assets.map(a => writeFile(path.join(repoPath, a.path), Buffer.from(a.content, a.encoding))),
|
||||
);
|
||||
if (entry.newPath) {
|
||||
await move(path.join(repoPath, entry.path), path.join(repoPath, entry.newPath));
|
||||
if (dataFiles.every(dataFile => dataFile.newPath)) {
|
||||
dataFiles.forEach(async dataFile => {
|
||||
await move(path.join(repoPath, dataFile.path), path.join(repoPath, dataFile.newPath!));
|
||||
});
|
||||
}
|
||||
|
||||
// commits files
|
||||
@ -162,8 +168,13 @@ export const getSchema = ({ repoPath }: { repoPath: string }) => {
|
||||
export const localGitMiddleware = ({ repoPath, logger }: GitOptions) => {
|
||||
const git = simpleGit(repoPath).silent(false);
|
||||
|
||||
// we can only perform a single git operation at any given time
|
||||
const mutex = withTimeout(new Mutex(), 3000, new Error('Request timed out'));
|
||||
|
||||
return async function(req: express.Request, res: express.Response) {
|
||||
let release;
|
||||
try {
|
||||
release = await mutex.acquire();
|
||||
const { body } = req;
|
||||
if (body.action === 'info') {
|
||||
res.json({
|
||||
@ -233,11 +244,23 @@ export const localGitMiddleware = ({ repoPath, logger }: GitOptions) => {
|
||||
const diffs = await getDiffs(git, branch, cmsBranch);
|
||||
const label = await git.raw(['config', branchDescription(cmsBranch)]);
|
||||
const status = label && labelToStatus(label.trim(), cmsLabelPrefix || '');
|
||||
const updatedAt =
|
||||
diffs.length >= 0
|
||||
? await runOnBranch(git, cmsBranch, async () => {
|
||||
const dates = await Promise.all(
|
||||
diffs.map(({ newPath }) => getUpdateDate(repoPath, newPath)),
|
||||
);
|
||||
return dates.reduce((a, b) => {
|
||||
return a > b ? a : b;
|
||||
});
|
||||
})
|
||||
: new Date();
|
||||
const unpublishedEntry = {
|
||||
collection,
|
||||
slug,
|
||||
status,
|
||||
diffs,
|
||||
updatedAt,
|
||||
};
|
||||
res.json(unpublishedEntry);
|
||||
} else {
|
||||
@ -276,13 +299,20 @@ export const localGitMiddleware = ({ repoPath, logger }: GitOptions) => {
|
||||
break;
|
||||
}
|
||||
case 'persistEntry': {
|
||||
const { entry, assets, options, cmsLabelPrefix } = body.params as PersistEntryParams;
|
||||
const {
|
||||
cmsLabelPrefix,
|
||||
entry,
|
||||
dataFiles = [entry as DataFile],
|
||||
assets,
|
||||
options,
|
||||
} = body.params as PersistEntryParams;
|
||||
|
||||
if (!options.useWorkflow) {
|
||||
await runOnBranch(git, branch, async () => {
|
||||
await commitEntry(git, repoPath, entry, assets, options.commitMessage);
|
||||
await commitEntry(git, repoPath, dataFiles, assets, options.commitMessage);
|
||||
});
|
||||
} else {
|
||||
const slug = entry.slug;
|
||||
const slug = dataFiles[0].slug;
|
||||
const collection = options.collectionName as string;
|
||||
const contentKey = generateContentKey(collection, slug);
|
||||
const cmsBranch = branchFromContentKey(contentKey);
|
||||
@ -300,7 +330,7 @@ export const localGitMiddleware = ({ repoPath, logger }: GitOptions) => {
|
||||
d => d.binary && !assets.map(a => a.path).includes(d.path),
|
||||
);
|
||||
await Promise.all(toDelete.map(f => fs.unlink(path.join(repoPath, f.path))));
|
||||
await commitEntry(git, repoPath, entry, assets, options.commitMessage);
|
||||
await commitEntry(git, repoPath, dataFiles, assets, options.commitMessage);
|
||||
|
||||
// add status for new entries
|
||||
if (!branchExists) {
|
||||
@ -378,12 +408,24 @@ export const localGitMiddleware = ({ repoPath, logger }: GitOptions) => {
|
||||
options: { commitMessage },
|
||||
} = body.params as DeleteFileParams;
|
||||
await runOnBranch(git, branch, async () => {
|
||||
await fs.unlink(path.join(repoPath, filePath));
|
||||
await deleteFile(repoPath, filePath);
|
||||
await commit(git, commitMessage);
|
||||
});
|
||||
res.json({ message: `deleted file ${filePath}` });
|
||||
break;
|
||||
}
|
||||
case 'deleteFiles': {
|
||||
const {
|
||||
paths,
|
||||
options: { commitMessage },
|
||||
} = body.params as DeleteFilesParams;
|
||||
await runOnBranch(git, branch, async () => {
|
||||
await Promise.all(paths.map(filePath => deleteFile(repoPath, filePath)));
|
||||
await commit(git, commitMessage);
|
||||
});
|
||||
res.json({ message: `deleted files ${paths.join(', ')}` });
|
||||
break;
|
||||
}
|
||||
case 'getDeployPreview': {
|
||||
res.json(null);
|
||||
break;
|
||||
@ -397,6 +439,8 @@ export const localGitMiddleware = ({ repoPath, logger }: GitOptions) => {
|
||||
} catch (e) {
|
||||
logger.error(`Error handling ${JSON.stringify(req.body)}: ${e.message}`);
|
||||
res.status(500).json({ error: 'Unknown error' });
|
||||
} finally {
|
||||
release && release();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
@ -54,13 +54,14 @@ export type PublishUnpublishedEntryParams = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type Entry = { slug: string; path: string; raw: string; newPath?: string };
|
||||
export type DataFile = { slug: string; path: string; raw: string; newPath?: string };
|
||||
|
||||
export type Asset = { path: string; content: string; encoding: 'base64' };
|
||||
|
||||
export type PersistEntryParams = {
|
||||
cmsLabelPrefix?: string;
|
||||
entry: Entry;
|
||||
entry?: DataFile;
|
||||
dataFiles?: DataFile[];
|
||||
assets: Asset[];
|
||||
options: {
|
||||
collectionName?: string;
|
||||
@ -91,3 +92,10 @@ export type DeleteFileParams = {
|
||||
commitMessage: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type DeleteFilesParams = {
|
||||
paths: string[];
|
||||
options: {
|
||||
commitMessage: string;
|
||||
};
|
||||
};
|
||||
|
@ -38,7 +38,7 @@ export const writeFile = async (filePath: string, content: Buffer | string) => {
|
||||
};
|
||||
|
||||
export const deleteFile = async (repoPath: string, filePath: string) => {
|
||||
await fs.unlink(path.join(repoPath, filePath));
|
||||
await fs.unlink(path.join(repoPath, filePath)).catch(() => undefined);
|
||||
};
|
||||
|
||||
const moveFile = async (from: string, to: string) => {
|
||||
@ -56,3 +56,10 @@ export const move = async (from: string, to: string) => {
|
||||
const allFiles = await listFiles(sourceDir, '', 100);
|
||||
await Promise.all(allFiles.map(file => moveFile(file, file.replace(sourceDir, destDir))));
|
||||
};
|
||||
|
||||
export const getUpdateDate = async (repoPath: string, filePath: string) => {
|
||||
return fs
|
||||
.stat(path.join(repoPath, filePath))
|
||||
.then(stat => stat.mtime)
|
||||
.catch(() => new Date());
|
||||
};
|
||||
|
@ -373,7 +373,7 @@ const reactSelectStyles = {
|
||||
: 'transparent',
|
||||
paddingLeft: '22px',
|
||||
}),
|
||||
menu: styles => ({ ...styles, right: 0, zIndex: 300 }),
|
||||
menu: styles => ({ ...styles, right: 0, zIndex: zIndex.zIndex300 }),
|
||||
container: styles => ({ ...styles, padding: '0 !important' }),
|
||||
indicatorSeparator: (styles, state) =>
|
||||
state.hasValue && state.selectProps.isClearable
|
||||
|
@ -7,6 +7,34 @@ import DateTime from 'react-datetime';
|
||||
import moment from 'moment';
|
||||
import { buttons } from 'netlify-cms-ui-default';
|
||||
|
||||
const NowButton = ({ t, handleChange }) => {
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
transform: translateY(-40px);
|
||||
width: fit-content;
|
||||
z-index: 1;
|
||||
`}
|
||||
>
|
||||
<button
|
||||
css={css`
|
||||
${buttons.button}
|
||||
${buttons.default}
|
||||
${buttons.lightBlue}
|
||||
${buttons.small}
|
||||
`}
|
||||
onClick={() => {
|
||||
handleChange(moment());
|
||||
}}
|
||||
>
|
||||
{t('editor.editorWidgets.datetime.now')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default class DateTimeControl extends React.Component {
|
||||
static propTypes = {
|
||||
field: PropTypes.object.isRequired,
|
||||
@ -114,7 +142,7 @@ export default class DateTimeControl extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { forID, value, classNameWrapper, setActiveStyle, t } = this.props;
|
||||
const { forID, value, classNameWrapper, setActiveStyle, t, isDisabled } = this.props;
|
||||
const { format, dateFormat, timeFormat } = this.formats;
|
||||
|
||||
return (
|
||||
@ -134,29 +162,7 @@ export default class DateTimeControl extends React.Component {
|
||||
inputProps={{ className: classNameWrapper, id: forID }}
|
||||
utc={this.pickerUtc}
|
||||
/>
|
||||
<div
|
||||
css={css`
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
transform: translateY(-40px);
|
||||
width: fit-content;
|
||||
z-index: 1;
|
||||
`}
|
||||
>
|
||||
<button
|
||||
css={css`
|
||||
${buttons.button}
|
||||
${buttons.default}
|
||||
${buttons.lightBlue}
|
||||
${buttons.small}
|
||||
`}
|
||||
onClick={() => {
|
||||
this.handleChange(moment());
|
||||
}}
|
||||
>
|
||||
{t('editor.editorWidgets.datetime.now')}
|
||||
</button>
|
||||
</div>
|
||||
{!isDisabled && <NowButton t={t} handleChange={v => this.handleChange(v)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -5,5 +5,6 @@ export default {
|
||||
summary: { type: 'string' },
|
||||
minimize_collapsed: { type: 'boolean' },
|
||||
label_singular: { type: 'string' },
|
||||
i18n: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
@ -38,7 +38,16 @@ export default class RawEditor extends React.Component {
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return !this.state.value.equals(nextState.value);
|
||||
return (
|
||||
!this.state.value.equals(nextState.value) ||
|
||||
nextProps.value !== Plain.serialize(nextState.value)
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.value !== this.props.value) {
|
||||
this.setState({ value: Plain.deserialize(this.props.value) });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -121,7 +121,9 @@ export default class Editor extends React.Component {
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return !this.state.value.equals(nextState.value);
|
||||
const raw = nextState.value.document.toJS();
|
||||
const markdown = slateToMarkdown(raw, { voidCodeBlock: this.codeBlockComponent });
|
||||
return !this.state.value.equals(nextState.value) || nextProps.value !== markdown;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@ -131,6 +133,14 @@ export default class Editor extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.value !== this.props.value) {
|
||||
this.setState({
|
||||
value: createSlateValue(this.props.value, { voidCodeBlock: !!this.codeBlockComponent }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleMarkClick = type => {
|
||||
this.editor.toggleMark(type).focus();
|
||||
};
|
||||
@ -178,7 +188,7 @@ export default class Editor extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { onAddAsset, getAsset, className, field, isShowModeToggle, t } = this.props;
|
||||
const { onAddAsset, getAsset, className, field, isShowModeToggle, t, isDisabled } = this.props;
|
||||
return (
|
||||
<div
|
||||
css={coreCss`
|
||||
@ -202,6 +212,7 @@ export default class Editor extends React.Component {
|
||||
hasBlock={this.hasBlock}
|
||||
isShowModeToggle={isShowModeToggle}
|
||||
t={t}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</EditorControlBar>
|
||||
<ClassNames>
|
||||
|
@ -71,6 +71,7 @@ export default class MarkdownControl extends React.Component {
|
||||
getEditorComponents,
|
||||
resolveWidget,
|
||||
t,
|
||||
isDisabled,
|
||||
} = this.props;
|
||||
|
||||
const { mode, pendingFocus } = this.state;
|
||||
@ -90,6 +91,7 @@ export default class MarkdownControl extends React.Component {
|
||||
resolveWidget={resolveWidget}
|
||||
pendingFocus={pendingFocus && this.setFocusReceived}
|
||||
t={t}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -81,6 +81,8 @@ export default class ObjectControl extends React.Component {
|
||||
editorControl: EditorControl,
|
||||
controlRef,
|
||||
parentIds,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
} = this.props;
|
||||
|
||||
if (field.get('widget') === 'hidden') {
|
||||
@ -89,6 +91,9 @@ export default class ObjectControl extends React.Component {
|
||||
const fieldName = field.get('name');
|
||||
const fieldValue = value && Map.isMap(value) ? value.get(fieldName) : value;
|
||||
|
||||
const isDuplicate = isFieldDuplicate && isFieldDuplicate(field);
|
||||
const isHidden = isFieldHidden && isFieldHidden(field);
|
||||
|
||||
return (
|
||||
<EditorControl
|
||||
key={key}
|
||||
@ -102,6 +107,10 @@ export default class ObjectControl extends React.Component {
|
||||
processControlRef={controlRef && controlRef.bind(this)}
|
||||
controlRef={controlRef}
|
||||
parentIds={parentIds}
|
||||
isDisabled={isDuplicate}
|
||||
isHidden={isHidden}
|
||||
isFieldDuplicate={isFieldDuplicate}
|
||||
isFieldHidden={isFieldHidden}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
export default {
|
||||
properties: {
|
||||
collapsed: { type: 'boolean' },
|
||||
i18n: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
Reference in New Issue
Block a user