Feat: nested collections (#3716)

This commit is contained in:
Erez Rokah 2020-06-18 10:11:37 +03:00 committed by GitHub
parent b4c47caf59
commit af7bbbd9a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
89 changed files with 8269 additions and 5619 deletions

View File

@ -1597,7 +1597,7 @@
"status": 200 "status": 200
}, },
{ {
"body": "{\"base_tree\":\"e774625f38ae12e6bdc27574f3238a20bf71b6ca\",\"tree\":[{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":null},{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"5ed1cbc40b517ab0b1dba1f9486d6c2723e7020e\"}]}", "body": "{\"base_tree\":\"e774625f38ae12e6bdc27574f3238a20bf71b6ca\",\"tree\":[{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"5ed1cbc40b517ab0b1dba1f9486d6c2723e7020e\"}]}",
"method": "POST", "method": "POST",
"url": "/.netlify/git/github/git/trees", "url": "/.netlify/git/github/git/trees",
"headers": { "headers": {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1284,7 +1284,7 @@
"status": 200 "status": 200
}, },
{ {
"body": "{\"base_tree\":\"d8e02516ac6e201f752b6c1916a2a850fa0ae2eb\",\"tree\":[{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":null},{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"5ed1cbc40b517ab0b1dba1f9486d6c2723e7020e\"}]}", "body": "{\"base_tree\":\"d8e02516ac6e201f752b6c1916a2a850fa0ae2eb\",\"tree\":[{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"5ed1cbc40b517ab0b1dba1f9486d6c2723e7020e\"}]}",
"method": "POST", "method": "POST",
"url": "/repos/forkOwner/repo/git/trees", "url": "/repos/forkOwner/repo/git/trees",
"headers": { "headers": {

View File

@ -1235,7 +1235,7 @@
"status": 200 "status": 200
}, },
{ {
"body": "{\"base_tree\":\"8ac793644b57607b35a15858b44ed7d90b794e73\",\"tree\":[{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":null},{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"5ed1cbc40b517ab0b1dba1f9486d6c2723e7020e\"}]}", "body": "{\"base_tree\":\"8ac793644b57607b35a15858b44ed7d90b794e73\",\"tree\":[{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"5ed1cbc40b517ab0b1dba1f9486d6c2723e7020e\"}]}",
"method": "POST", "method": "POST",
"url": "/repos/owner/repo/git/trees", "url": "/repos/owner/repo/git/trees",
"headers": { "headers": {

View File

@ -1183,7 +1183,7 @@
"status": 200 "status": 200
}, },
{ {
"body": "{\"base_tree\":\"c502fbb54c429d6b7fc056e86ad5739bb96283a0\",\"tree\":[{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":null},{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"5ed1cbc40b517ab0b1dba1f9486d6c2723e7020e\"}]}", "body": "{\"base_tree\":\"c502fbb54c429d6b7fc056e86ad5739bb96283a0\",\"tree\":[{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"5ed1cbc40b517ab0b1dba1f9486d6c2723e7020e\"}]}",
"method": "POST", "method": "POST",
"url": "/repos/owner/repo/git/trees", "url": "/repos/owner/repo/git/trees",
"headers": { "headers": {

View File

@ -1462,7 +1462,7 @@
"status": 200 "status": 200
}, },
{ {
"body": "{\"base_tree\":\"a9e732737e1a806d311ba91ff6c42de528a69b5d\",\"tree\":[{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":null},{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"5ed1cbc40b517ab0b1dba1f9486d6c2723e7020e\"}]}", "body": "{\"base_tree\":\"a9e732737e1a806d311ba91ff6c42de528a69b5d\",\"tree\":[{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"5ed1cbc40b517ab0b1dba1f9486d6c2723e7020e\"}]}",
"method": "POST", "method": "POST",
"url": "/repos/forkOwner/repo/git/trees", "url": "/repos/forkOwner/repo/git/trees",
"headers": { "headers": {

View File

@ -1286,7 +1286,7 @@
"status": 200 "status": 200
}, },
{ {
"body": "{\"base_tree\":\"49f07bd3fa298db5d2f430a5b04bbfa60978eed8\",\"tree\":[{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":null},{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"5ed1cbc40b517ab0b1dba1f9486d6c2723e7020e\"}]}", "body": "{\"base_tree\":\"49f07bd3fa298db5d2f430a5b04bbfa60978eed8\",\"tree\":[{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"5ed1cbc40b517ab0b1dba1f9486d6c2723e7020e\"}]}",
"method": "POST", "method": "POST",
"url": "/repos/owner/repo/git/trees", "url": "/repos/owner/repo/git/trees",
"headers": { "headers": {

View File

@ -1234,7 +1234,7 @@
"status": 200 "status": 200
}, },
{ {
"body": "{\"base_tree\":\"1c11a4511b2208a3a3d159035962f5ac267df45c\",\"tree\":[{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":null},{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"5ed1cbc40b517ab0b1dba1f9486d6c2723e7020e\"}]}", "body": "{\"base_tree\":\"1c11a4511b2208a3a3d159035962f5ac267df45c\",\"tree\":[{\"path\":\"content/posts/1970-01-01-first-title.md\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"5ed1cbc40b517ab0b1dba1f9486d6c2723e7020e\"}]}",
"method": "POST", "method": "POST",
"url": "/repos/owner/repo/git/trees", "url": "/repos/owner/repo/git/trees",
"headers": { "headers": {

View File

@ -22,8 +22,10 @@ import {
populateEntry, populateEntry,
publishAndCreateNewEntryInEditor, publishAndCreateNewEntryInEditor,
publishAndDuplicateEntryInEditor, publishAndDuplicateEntryInEditor,
assertNotification,
assertFieldValidationError,
} from '../utils/steps'; } from '../utils/steps';
import { workflowStatus, editorStatus, publishTypes } from '../utils/constants'; import { workflowStatus, editorStatus, publishTypes, notifications } from '../utils/constants';
const entry1 = { const entry1 = {
title: 'first title', title: 'first title',
@ -192,4 +194,123 @@ describe('Test Backend Editorial Workflow', () => {
updateWorkflowStatusInEditor(editorStatus.ready); updateWorkflowStatusInEditor(editorStatus.ready);
publishAndDuplicateEntryInEditor(entry1); publishAndDuplicateEntryInEditor(entry1);
}); });
const inSidebar = func => {
cy.get('[class*=SidebarNavList]').within(func);
};
const inGrid = func => {
cy.get('[class*=CardsGrid]').within(func);
};
it('can access nested collection items', () => {
login();
inSidebar(() => cy.contains('a', 'Pages').click());
inSidebar(() => cy.contains('a', 'Directory'));
inGrid(() => cy.contains('a', 'Root Page'));
inGrid(() => cy.contains('a', 'Directory'));
inSidebar(() => cy.contains('a', 'Directory').click());
inGrid(() => cy.contains('a', 'Sub Directory'));
inGrid(() => cy.contains('a', 'Another Sub Directory'));
inSidebar(() => cy.contains('a', 'Sub Directory').click());
inGrid(() => cy.contains('a', 'Nested Directory'));
cy.url().should(
'eq',
'http://localhost:8080/#/collections/pages/filter/directory/sub-directory',
);
inSidebar(() => cy.contains('a', 'Pages').click());
inSidebar(() => cy.contains('a', 'Pages').click());
inGrid(() => cy.contains('a', 'Another Sub Directory').should('not.exist'));
});
it('can navigate to nested entry', () => {
login();
inSidebar(() => cy.contains('a', 'Pages').click());
inSidebar(() => cy.contains('a', 'Directory').click());
inGrid(() => cy.contains('a', 'Another Sub Directory').click());
cy.url().should(
'eq',
'http://localhost:8080/#/collections/pages/entries/directory/another-sub-directory/index',
);
});
it(`can create a new entry with custom path`, () => {
login();
inSidebar(() => cy.contains('a', 'Pages').click());
inSidebar(() => cy.contains('a', 'Directory').click());
inSidebar(() => cy.contains('a', 'Sub Directory').click());
cy.contains('a', 'New Page').click();
cy.get('[id^="path-field"]').should('have.value', 'directory/sub-directory');
cy.get('[id^="path-field"]').type('/new-path');
cy.get('[id^="title-field"]').type('New Path Title');
cy.clock().then(clock => {
clock.tick(150);
});
cy.contains('button', 'Save').click();
assertNotification(notifications.saved);
updateWorkflowStatusInEditor(editorStatus.ready);
publishEntryInEditor(publishTypes.publishNow);
exitEditor();
inGrid(() => cy.contains('a', 'New Path Title'));
inSidebar(() => cy.contains('a', 'Directory').click());
inSidebar(() => cy.contains('a', 'Directory').click());
inGrid(() => cy.contains('a', 'New Path Title').should('not.exist'));
});
it(`can't create an entry with an existing path`, () => {
login();
inSidebar(() => cy.contains('a', 'Pages').click());
inSidebar(() => cy.contains('a', 'Directory').click());
inSidebar(() => cy.contains('a', 'Sub Directory').click());
cy.contains('a', 'New Page').click();
cy.get('[id^="title-field"]').type('New Path Title');
cy.clock().then(clock => {
clock.tick(150);
});
cy.contains('button', 'Save').click();
assertFieldValidationError({
message: `Path 'directory/sub-directory' already exists`,
fieldLabel: 'Path',
});
});
it('can move an existing entry to a new path', () => {
login();
inSidebar(() => cy.contains('a', 'Pages').click());
inGrid(() => cy.contains('a', 'Directory').click());
cy.get('[id^="path-field"]').should('have.value', 'directory');
cy.get('[id^="path-field"]').clear();
cy.get('[id^="path-field"]').type('new-directory');
cy.get('[id^="title-field"]').clear();
cy.get('[id^="title-field"]').type('New Directory');
cy.clock().then(clock => {
clock.tick(150);
});
cy.contains('button', 'Save').click();
assertNotification(notifications.saved);
updateWorkflowStatusInEditor(editorStatus.ready);
publishEntryInEditor(publishTypes.publishNow);
exitEditor();
inSidebar(() => cy.contains('a', 'New Directory').click());
inGrid(() => cy.contains('a', 'Sub Directory'));
inGrid(() => cy.contains('a', 'Another Sub Directory'));
});
}); });

View File

@ -1,3 +1,4 @@
import '../utils/dismiss-local-backup';
import { import {
login, login,
newPost, newPost,

View File

@ -107,7 +107,7 @@ async function deleteRepositories({ owner, repo, tempDir }) {
console.log('Deleting repository', `${owner}/${repo}`); console.log('Deleting repository', `${owner}/${repo}`);
await fs.remove(tempDir); await fs.remove(tempDir);
let client = getGitLabClient(token); const client = getGitLabClient(token);
await client.Projects.remove(`${owner}/${repo}`).catch(errorHandler); await client.Projects.remove(`${owner}/${repo}`).catch(errorHandler);
} }

View File

@ -34,8 +34,8 @@ const matchRoute = (route, fetchArgs) => {
const options = fetchArgs[1]; const options = fetchArgs[1];
const method = options && options.method ? options.method : 'GET'; const method = options && options.method ? options.method : 'GET';
let body = options && options.body; const body = options && options.body;
let routeBody = route.body; const routeBody = route.body;
let bodyMatch = false; let bodyMatch = false;
if (routeBody?.encoding === 'base64' && ['File', 'Blob'].includes(body?.constructor.name)) { if (routeBody?.encoding === 'base64' && ['File', 'Blob'].includes(body?.constructor.name)) {

View File

@ -43,6 +43,7 @@ const retrieveRecordedExpectations = async () => {
(Host.includes('bitbucket.org') && httpRequest.path.includes('info/lfs')) || (Host.includes('bitbucket.org') && httpRequest.path.includes('info/lfs')) ||
Host.includes('api.media.atlassian.com') || Host.includes('api.media.atlassian.com') ||
Host.some(host => host.includes('netlify.com')) || Host.some(host => host.includes('netlify.com')) ||
Host.some(host => host.includes('netlify.app')) ||
Host.some(host => host.includes('s3.amazonaws.com')) Host.some(host => host.includes('s3.amazonaws.com'))
); );
}); });

View File

@ -76,7 +76,7 @@ function assertColorOn(cssProperty, color, opts) {
} }
function exitEditor() { function exitEditor() {
cy.contains('a[href^="#/collections/"]', 'Writing in').click(); cy.contains('a', 'Writing in').click();
} }
function goToWorkflow() { function goToWorkflow() {
@ -684,4 +684,6 @@ module.exports = {
duplicatePostAndPublish, duplicatePostAndPublish,
publishAndCreateNewEntryInEditor, publishAndCreateNewEntryInEditor,
publishAndDuplicateEntryInEditor, publishAndDuplicateEntryInEditor,
assertNotification,
assertFieldValidationError,
}; };

View File

@ -45,8 +45,6 @@ collections: # A list of collections the CMS should be able to edit
tagname: '' tagname: ''
- { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' } - { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' }
meta:
- { label: 'SEO Description', name: 'description', widget: 'text' }
- name: 'faq' # Used in routes, ie.: /admin/collections/:slug/edit - name: 'faq' # Used in routes, ie.: /admin/collections/:slug/edit
label: 'FAQ' # Used in the UI label: 'FAQ' # Used in the UI
@ -247,3 +245,14 @@ collections: # A list of collections the CMS should be able to edit
- { label: 'Date', name: 'date', widget: 'date' } - { label: 'Date', name: 'date', widget: 'date' }
- { label: 'Image', name: 'image', widget: 'image' } - { label: 'Image', name: 'image', widget: 'image' }
- { label: 'File', name: 'file', widget: 'file' } - { label: 'File', name: 'file', widget: 'file' }
- name: pages # a nested collection
label: Pages
label_singular: 'Page'
folder: _pages
create: true
nested: { depth: 100 }
fields:
- label: Title
name: title
widget: string
meta: { path: { widget: string, label: 'Path', index_file: 'index' } }

View File

@ -74,6 +74,36 @@
content: "---\ntitle: \"This FAQ item # " + i + "\"\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# Loren ipsum dolor sit amet" content: "---\ntitle: \"This FAQ item # " + i + "\"\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# Loren ipsum dolor sit amet"
} }
} }
window.repoFiles._pages = {
directory: {
'sub-directory': {
'nested-directory': {
'index.md': {
path: '_pages/directory/sub-directory/nested-directory/index.md',
content: '---\ntitle: "Nested Directory"\n---\n',
},
},
'index.md': {
path: '_pages/directory/sub-directory/index.md',
content: '---\ntitle: "Sub Directory"\n---\n',
},
},
'another-sub-directory': {
'index.md': {
path: '_pages/directory/another-sub-directory/index.md',
content: '---\ntitle: "Another Sub Directory"\n---\n',
},
},
'index.md': {
path: '_pages/directory/index.md',
content: '---\ntitle: "Directory"\n---\n',
},
},
'index.md': {
path: '_pages/index.md',
content: '---\ntitle: "Root Page"\n---\n',
},
};
</script> </script>
</head> </head>
<body> <body>

View File

@ -45,8 +45,6 @@ collections: # A list of collections the CMS should be able to edit
tagname: '' tagname: ''
- { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' } - { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' }
meta:
- { label: 'SEO Description', name: 'description', widget: 'text' }
- name: 'faq' # Used in routes, ie.: /admin/collections/:slug/edit - name: 'faq' # Used in routes, ie.: /admin/collections/:slug/edit
label: 'FAQ' # Used in the UI label: 'FAQ' # Used in the UI
@ -247,3 +245,14 @@ collections: # A list of collections the CMS should be able to edit
- { label: 'Date', name: 'date', widget: 'date' } - { label: 'Date', name: 'date', widget: 'date' }
- { label: 'Image', name: 'image', widget: 'image' } - { label: 'Image', name: 'image', widget: 'image' }
- { label: 'File', name: 'file', widget: 'file' } - { label: 'File', name: 'file', widget: 'file' }
- name: pages # a nested collection
label: Pages
label_singular: 'Page'
folder: _pages
create: true
nested: { depth: 100 }
fields:
- label: Title
name: title
widget: string
meta: { path: { widget: string, label: 'Path', index_file: 'index' } }

View File

@ -74,6 +74,36 @@
content: "---\ntitle: \"This FAQ item # " + i + "\"\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# Loren ipsum dolor sit amet" content: "---\ntitle: \"This FAQ item # " + i + "\"\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# Loren ipsum dolor sit amet"
} }
} }
window.repoFiles._pages = {
directory: {
'sub-directory': {
'nested-directory': {
'index.md': {
path: '_pages/directory/sub-directory/nested-directory/index.md',
content: '---\ntitle: "Nested Directory"\n---\n',
},
},
'index.md': {
path: '_pages/directory/sub-directory/index.md',
content: '---\ntitle: "Sub Directory"\n---\n',
},
},
'another-sub-directory': {
'index.md': {
path: '_pages/directory/another-sub-directory/index.md',
content: '---\ntitle: "Another Sub Directory"\n---\n',
},
},
'index.md': {
path: '_pages/directory/index.md',
content: '---\ntitle: "Directory"\n---\n',
},
},
'index.md': {
path: '_pages/index.md',
content: '---\ntitle: "Root Page"\n---\n',
},
};
</script> </script>
</head> </head>
<body> <body>

View File

@ -28,6 +28,7 @@ import {
readFileMetadata, readFileMetadata,
throwOnConflictingBranches, throwOnConflictingBranches,
} from 'netlify-cms-lib-util'; } from 'netlify-cms-lib-util';
import { dirname } from 'path';
import { oneLine } from 'common-tags'; import { oneLine } from 'common-tags';
import { parse } from 'what-the-diff'; import { parse } from 'what-the-diff';
@ -364,8 +365,8 @@ export default class API {
}; };
}; };
listFiles = async (path: string, depth = 1, pagelen = 20) => { listFiles = async (path: string, depth = 1, pagelen: number, branch: string) => {
const node = await this.branchCommitSha(this.branch); const node = await this.branchCommitSha(branch);
const result: BitBucketSrcResult = await this.requestJSON({ const result: BitBucketSrcResult = await this.requestJSON({
url: `${this.repoURL}/src/${node}/${path}`, url: `${this.repoURL}/src/${node}/${path}`,
params: { params: {
@ -398,11 +399,12 @@ export default class API {
})), })),
])(cursor.data!.getIn(['links', action])); ])(cursor.data!.getIn(['links', action]));
listAllFiles = async (path: string, depth = 1) => { listAllFiles = async (path: string, depth: number, branch: string) => {
const { cursor: initialCursor, entries: initialEntries } = await this.listFiles( const { cursor: initialCursor, entries: initialEntries } = await this.listFiles(
path, path,
depth, depth,
100, 100,
branch,
); );
const entries = [...initialEntries]; const entries = [...initialEntries];
let currentCursor = initialCursor; let currentCursor = initialCursor;
@ -418,7 +420,7 @@ export default class API {
}; };
async uploadFiles( async uploadFiles(
files: (Entry | AssetProxy | DeleteEntry)[], files: { path: string; newPath?: string; delete?: boolean }[],
{ {
commitMessage, commitMessage,
branch, branch,
@ -426,10 +428,14 @@ export default class API {
}: { commitMessage: string; branch: string; parentSha?: string }, }: { commitMessage: string; branch: string; parentSha?: string },
) { ) {
const formData = new FormData(); const formData = new FormData();
const toMove: { from: string; to: string; contentBlob: Blob }[] = [];
files.forEach(file => { files.forEach(file => {
if ((file as DeleteEntry).delete) { if (file.delete) {
// delete the file // delete the file
formData.append('files', file.path); formData.append('files', file.path);
} else if (file.newPath) {
const contentBlob = get(file, 'fileObj', new Blob([(file as Entry).raw]));
toMove.push({ from: file.path, to: file.newPath, contentBlob });
} else { } else {
// add/modify the file // add/modify the file
const contentBlob = get(file, 'fileObj', new Blob([(file as Entry).raw])); const contentBlob = get(file, 'fileObj', new Blob([(file as Entry).raw]));
@ -437,6 +443,30 @@ export default class API {
formData.append(file.path, contentBlob, basename(file.path)); formData.append(file.path, contentBlob, basename(file.path));
} }
}); });
for (const { from, to, contentBlob } of toMove) {
const sourceDir = dirname(from);
const destDir = dirname(to);
const filesBranch = parentSha ? this.branch : branch;
const files = await this.listAllFiles(sourceDir, 100, filesBranch);
for (const file of files) {
// to move a file in Bitbucket we need to delete the old path
// and upload the file content to the new path
// NOTE: this is very wasteful, and also the Bitbucket `diff` API
// reports these files as deleted+added instead of renamed
// delete current path
formData.append('files', file.path);
// create in new path
const content =
file.path === from
? contentBlob
: await this.readFile(file.path, null, {
branch: filesBranch,
parseText: false,
});
formData.append(file.path.replace(sourceDir, destDir), content, basename(file.path));
}
}
if (commitMessage) { if (commitMessage) {
formData.append('message', commitMessage); formData.append('message', commitMessage);
} }
@ -538,19 +568,20 @@ export default class API {
}, },
}); });
return parse(rawDiff).map(d => { const diffs = parse(rawDiff).map(d => {
const oldPath = d.oldPath?.replace(/b\//, '') || ''; const oldPath = d.oldPath?.replace(/b\//, '') || '';
const newPath = d.newPath?.replace(/b\//, '') || ''; const newPath = d.newPath?.replace(/b\//, '') || '';
const path = newPath || (oldPath as string); const path = newPath || (oldPath as string);
return { return {
oldPath, oldPath,
newPath, newPath,
binary: d.binary || /.svg$/.test(path),
status: d.status, status: d.status,
newFile: d.status === 'added', newFile: d.status === 'added',
path, path,
binary: d.binary || /.svg$/.test(path),
}; };
}); });
return diffs;
} }
async editorialWorkflowGit(files: (Entry | AssetProxy)[], entry: Entry, options: PersistOptions) { async editorialWorkflowGit(files: (Entry | AssetProxy)[], entry: Entry, options: PersistOptions) {
@ -573,8 +604,8 @@ export default class API {
// mark files for deletion // mark files for deletion
const diffs = await this.getDifferences(branch); const diffs = await this.getDifferences(branch);
const toDelete: DeleteEntry[] = []; const toDelete: DeleteEntry[] = [];
for (const diff of diffs) { for (const diff of diffs.filter(d => d.binary && d.status !== 'deleted')) {
if (!files.some(file => file.path === diff.newPath)) { if (!files.some(file => file.path === diff.path)) {
toDelete.push({ path: diff.path, delete: true }); toDelete.push({ path: diff.path, delete: true });
} }
} }
@ -637,47 +668,6 @@ export default class API {
return pullRequests[0]; return pullRequests[0];
} }
async retrieveMetadata(contentKey: string) {
const { collection, slug } = parseContentKey(contentKey);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
const diff = await this.getDifferences(branch);
const { newPath: path, newFile } = diff.find(d => !d.binary) as {
newPath: string;
newFile: boolean;
};
// TODO: get real file id
const mediaFiles = await Promise.all(
diff.filter(d => d.newPath !== path).map(d => ({ path: d.newPath, id: null })),
);
const label = await this.getPullRequestLabel(pullRequest.id);
const status = labelToStatus(label);
const timeStamp = pullRequest.updated_on;
return { branch, collection, slug, path, status, newFile, mediaFiles, timeStamp };
}
async readUnpublishedBranchFile(contentKey: string) {
const {
branch,
collection,
slug,
path,
status,
newFile,
mediaFiles,
timeStamp,
} = await this.retrieveMetadata(contentKey);
const fileData = (await this.readFile(path, null, { branch })) as string;
return {
slug,
metaData: { branch, collection, objects: { entry: { path, mediaFiles } }, status, timeStamp },
fileData,
isModification: !newFile,
};
}
async listUnpublishedBranches() { async listUnpublishedBranches() {
console.log( console.log(
'%c Checking for Unpublished entries', '%c Checking for Unpublished entries',
@ -690,6 +680,26 @@ export default class API {
return branches; return branches;
} }
async retrieveUnpublishedEntryData(contentKey: string) {
const { collection, slug } = parseContentKey(contentKey);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
const diffs = await this.getDifferences(branch);
const label = await this.getPullRequestLabel(pullRequest.id);
const status = labelToStatus(label);
const updatedAt = pullRequest.updated_on;
return {
collection,
slug,
status,
// TODO: get real id
diffs: diffs
.filter(d => d.status !== 'deleted')
.map(d => ({ path: d.path, newFile: d.newFile, id: '' })),
updatedAt,
};
}
async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) { async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
const contentKey = generateContentKey(collection, slug); const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey); const branch = branchFromContentKey(contentKey);

View File

@ -23,7 +23,6 @@ import {
Config, Config,
ImplementationFile, ImplementationFile,
unpublishedEntries, unpublishedEntries,
UnpublishedEntryMediaFile,
runWithLock, runWithLock,
AsyncLock, AsyncLock,
asyncLock, asyncLock,
@ -38,6 +37,7 @@ import {
localForage, localForage,
allEntriesByFolder, allEntriesByFolder,
AccessTokenError, AccessTokenError,
branchFromContentKey,
} from 'netlify-cms-lib-util'; } from 'netlify-cms-lib-util';
import { NetlifyAuthenticator } from 'netlify-cms-lib-auth'; import { NetlifyAuthenticator } from 'netlify-cms-lib-auth';
import AuthenticationPage from './AuthenticationPage'; import AuthenticationPage from './AuthenticationPage';
@ -299,7 +299,7 @@ export default class BitbucketBackend implements Implementation {
let cursor: Cursor; let cursor: Cursor;
const listFiles = () => const listFiles = () =>
this.api!.listFiles(folder, depth).then(({ entries, cursor: c }) => { this.api!.listFiles(folder, depth, 20, this.branch).then(({ entries, cursor: c }) => {
cursor = c.mergeMeta({ extension }); cursor = c.mergeMeta({ extension });
return entries.filter(e => filterByExtension(e, extension)); return entries.filter(e => filterByExtension(e, extension));
}); });
@ -323,7 +323,7 @@ export default class BitbucketBackend implements Implementation {
} }
async listAllFiles(folder: string, extension: string, depth: number) { async listAllFiles(folder: string, extension: string, depth: number) {
const files = await this.api!.listAllFiles(folder, depth); const files = await this.api!.listAllFiles(folder, depth, this.branch);
const filtered = files.filter(file => filterByExtension(file, extension)); const filtered = files.filter(file => filterByExtension(file, extension));
return filtered; return filtered;
} }
@ -371,7 +371,7 @@ export default class BitbucketBackend implements Implementation {
} }
getMedia(mediaFolder = this.mediaFolder) { getMedia(mediaFolder = this.mediaFolder) {
return this.api!.listAllFiles(mediaFolder).then(files => return this.api!.listAllFiles(mediaFolder, 1, this.branch).then(files =>
files.map(({ id, name, path }) => ({ id, name, path, displayURL: { id, path } })), files.map(({ id, name, path }) => ({ id, name, path, displayURL: { id, path } })),
); );
} }
@ -509,31 +509,26 @@ export default class BitbucketBackend implements Implementation {
}); });
} }
loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) { async loadMediaFile(path: string, id: string, { branch }: { branch: string }) {
const readFile = ( const readFile = async (
path: string, path: string,
id: string | null | undefined, id: string | null | undefined,
{ parseText }: { parseText: boolean }, { parseText }: { parseText: boolean },
) => this.api!.readFile(path, id, { branch, parseText }); ) => {
const content = await this.api!.readFile(path, id, { branch, parseText });
return getMediaAsBlob(file.path, null, readFile).then(blob => { return content;
const name = basename(file.path); };
const fileObj = blobToFileObj(name, blob); const blob = await getMediaAsBlob(path, id, readFile);
return { const name = basename(path);
id: file.path, const fileObj = blobToFileObj(name, blob);
displayURL: URL.createObjectURL(fileObj), return {
path: file.path, id: path,
name, displayURL: URL.createObjectURL(fileObj),
size: fileObj.size, path,
file: fileObj, name,
}; size: fileObj.size,
}); file: fileObj,
} };
async loadEntryMediaFiles(branch: string, files: UnpublishedEntryMediaFile[]) {
const mediaFiles = await Promise.all(files.map(file => this.loadMediaFile(branch, file)));
return mediaFiles;
} }
async unpublishedEntries() { async unpublishedEntries() {
@ -542,37 +537,47 @@ export default class BitbucketBackend implements Implementation {
branches.map(branch => contentKeyFromBranch(branch)), branches.map(branch => contentKeyFromBranch(branch)),
); );
const readUnpublishedBranchFile = (contentKey: string) => const ids = await unpublishedEntries(listEntriesKeys);
this.api!.readUnpublishedBranchFile(contentKey); return ids;
return unpublishedEntries(listEntriesKeys, readUnpublishedBranchFile, API_NAME);
} }
async unpublishedEntry( async unpublishedEntry({
collection: string, id,
slug: string, collection,
{ slug,
loadEntryMediaFiles = (branch: string, files: UnpublishedEntryMediaFile[]) => }: {
this.loadEntryMediaFiles(branch, files), id?: string;
} = {}, collection?: string;
) { slug?: string;
}) {
if (id) {
const data = await this.api!.retrieveUnpublishedEntryData(id);
return data;
} else if (collection && slug) {
const entryId = generateContentKey(collection, slug);
const data = await this.api!.retrieveUnpublishedEntryData(entryId);
return data;
} else {
throw new Error('Missing unpublished entry id or collection and slug');
}
}
getBranch(collection: string, slug: string) {
const contentKey = generateContentKey(collection, slug); const contentKey = generateContentKey(collection, slug);
const data = await this.api!.readUnpublishedBranchFile(contentKey); const branch = branchFromContentKey(contentKey);
const mediaFiles = await loadEntryMediaFiles( return branch;
data.metaData.branch, }
// TODO: fix this
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
// @ts-ignore const branch = this.getBranch(collection, slug);
data.metaData.objects.entry.mediaFiles, const data = (await this.api!.readFile(path, id, { branch })) as string;
); return data;
return { }
slug,
file: { path: data.metaData.objects.entry.path, id: null }, async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
data: data.fileData as string, const branch = this.getBranch(collection, slug);
metaData: data.metaData, const mediaFile = await this.loadMediaFile(path, id, { branch });
mediaFiles, return mediaFile;
isModification: data.isModification,
};
} }
async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) { async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {

View File

@ -18,7 +18,6 @@ import {
entriesByFiles, entriesByFiles,
Config, Config,
ImplementationFile, ImplementationFile,
UnpublishedEntryMediaFile,
parsePointerFile, parsePointerFile,
getLargeMediaPatternsFromGitAttributesFile, getLargeMediaPatternsFromGitAttributesFile,
getPointerFileForMediaFileObj, getPointerFileForMediaFileObj,
@ -394,34 +393,27 @@ export default class GitGateway implements Implementation {
return this.backend!.getEntry(path); return this.backend!.getEntry(path);
} }
async loadEntryMediaFiles(branch: string, files: UnpublishedEntryMediaFile[]) { async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
return this.backend!.unpublishedEntryDataFile(collection, slug, path, id);
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const client = await this.getLargeMediaClient(); const client = await this.getLargeMediaClient();
const backend = this.backend as GitLabBackend | GitHubBackend; if (client.enabled && client.matchPath(path)) {
if (!client.enabled) { const branch = this.backend!.getBranch(collection, slug);
return backend!.loadEntryMediaFiles(branch, files); const url = await this.getLargeMediaDisplayURL({ path, id }, branch);
return {
id,
name: basename(path),
path,
url,
displayURL: url,
file: new File([], name),
size: 0,
};
} else {
return this.backend!.unpublishedEntryMediaFile(collection, slug, path, id);
} }
const mediaFiles = await Promise.all(
files.map(async file => {
if (client.matchPath(file.path)) {
const { path, id } = file;
const url = await this.getLargeMediaDisplayURL({ path, id }, branch);
return {
id,
name: basename(path),
path,
url,
displayURL: url,
file: new File([], name),
size: 0,
};
} else {
return backend!.loadMediaFile(branch, file);
}
}),
);
return mediaFiles;
} }
getMedia(mediaFolder = this.mediaFolder) { getMedia(mediaFolder = this.mediaFolder) {
@ -597,10 +589,8 @@ export default class GitGateway implements Implementation {
unpublishedEntries() { unpublishedEntries() {
return this.backend!.unpublishedEntries(); return this.backend!.unpublishedEntries();
} }
unpublishedEntry(collection: string, slug: string) { unpublishedEntry({ id, collection, slug }: { id?: string; collection?: string; slug?: string }) {
return this.backend!.unpublishedEntry(collection, slug, { return this.backend!.unpublishedEntry({ id, collection, slug });
loadEntryMediaFiles: (branch, files) => this.loadEntryMediaFiles(branch, files),
});
} }
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) { updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
return this.backend!.updateUnpublishedEntryStatus(collection, slug, newStatus); return this.backend!.updateUnpublishedEntryStatus(collection, slug, newStatus);

View File

@ -29,6 +29,7 @@ import {
ApiRequest, ApiRequest,
throwOnConflictingBranches, throwOnConflictingBranches,
} from 'netlify-cms-lib-util'; } from 'netlify-cms-lib-util';
import { dirname } from 'path';
import { Octokit } from '@octokit/rest'; import { Octokit } from '@octokit/rest';
type GitHubUser = Octokit.UsersGetAuthenticatedResponse; type GitHubUser = Octokit.UsersGetAuthenticatedResponse;
@ -154,6 +155,24 @@ const getTreeFiles = (files: GitHubCompareFiles) => {
return treeFiles; return treeFiles;
}; };
type Diff = {
path: string;
newFile: boolean;
sha: string;
binary: boolean;
};
const diffFromFile = (diff: Octokit.ReposCompareCommitsResponseFilesItem): Diff => {
return {
path: diff.filename,
newFile: diff.status === 'added',
sha: diff.sha,
// media files diffs don't have a patch attribute, except svg files
// renamed files don't have a patch attribute too
binary: (diff.status !== 'renamed' && !diff.patch) || diff.filename.endsWith('.svg'),
};
};
let migrationNotified = false; let migrationNotified = false;
export default class API { export default class API {
@ -497,7 +516,9 @@ export default class API {
// since the contributor doesn't have access to set labels // since the contributor doesn't have access to set labels
// a branch without a pr (or a closed pr) means a 'draft' entry // a branch without a pr (or a closed pr) means a 'draft' entry
// a branch with an opened pr means a 'pending_review' entry // a branch with an opened pr means a 'pending_review' entry
const data = await this.getBranch(branch); const data = await this.getBranch(branch).catch(() => {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
});
// since we get all (open and closed) pull requests by branch name, make sure to filter by head sha // since we get all (open and closed) pull requests by branch name, make sure to filter by head sha
const pullRequest = pullRequests.filter(pr => pr.head.sha === data.commit.sha)[0]; const pullRequest = pullRequests.filter(pr => pr.head.sha === data.commit.sha)[0];
// if no pull request is found for the branch we return a mocked one // if no pull request is found for the branch we return a mocked one
@ -552,65 +573,22 @@ export default class API {
} }
} }
matchingEntriesFromDiffs(diffs: Octokit.ReposCompareCommitsResponseFilesItem[]) { async retrieveUnpublishedEntryData(contentKey: string) {
// media files don't have a patch attribute, except svg files
const matchingEntries = diffs
.filter(d => d.patch && !d.filename.endsWith('.svg'))
.map(f => ({ path: f.filename, newFile: f.status === 'added' }));
return matchingEntries;
}
async retrieveMetadata(contentKey: string) {
const { collection, slug } = this.parseContentKey(contentKey); const { collection, slug } = this.parseContentKey(contentKey);
const branch = branchFromContentKey(contentKey); const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch); const pullRequest = await this.getBranchPullRequest(branch);
const { files: diffs } = await this.getDifferences(this.branch, pullRequest.head.sha); const { files } = await this.getDifferences(this.branch, pullRequest.head.sha);
const matchingEntries = this.matchingEntriesFromDiffs(diffs); const diffs = files.map(diffFromFile);
let entry = matchingEntries[0];
if (matchingEntries.length <= 0) {
// this can happen if there is an empty diff for some reason
// we traverse the commits history to infer the entry
const commits = await this.getPullRequestCommits(pullRequest.number);
for (const commit of commits) {
const { files: diffs } = await this.getDifferences(this.branch, commit.sha);
const matchingEntries = this.matchingEntriesFromDiffs(diffs);
entry = matchingEntries[0];
if (entry) {
break;
}
}
if (!entry) {
console.error(
'Unable to locate entry from diff',
JSON.stringify({ branch, pullRequest, diffs, matchingEntries }),
);
throw new EditorialWorkflowError('content is not under editorial workflow', true);
}
} else if (matchingEntries.length > 1) {
// this only works for folder collections
const entryBySlug = matchingEntries.filter(e => e.path.includes(slug))[0];
entry = entryBySlug || entry;
if (!entryBySlug) {
console.warn(
`Expected 1 matching entry from diff, but received '${matchingEntries.length}'. Matched '${entry.path}'`,
JSON.stringify({ branch, pullRequest, diffs, matchingEntries }),
);
}
}
const { path, newFile } = entry;
const mediaFiles = diffs
.filter(d => d.filename !== path)
.map(({ filename: path, sha: id }) => ({
path,
id,
}));
const label = pullRequest.labels.find(l => isCMSLabel(l.name)) as { name: string }; const label = pullRequest.labels.find(l => isCMSLabel(l.name)) as { name: string };
const status = labelToStatus(label.name); const status = labelToStatus(label.name);
const timeStamp = pullRequest.updated_at; const updatedAt = pullRequest.updated_at;
return { branch, collection, slug, path, status, newFile, mediaFiles, timeStamp, pullRequest }; return {
collection,
slug,
status,
diffs: diffs.map(d => ({ path: d.path, newFile: d.newFile, id: d.sha })),
updatedAt,
};
} }
async readFile( async readFile(
@ -712,45 +690,6 @@ export default class API {
} }
} }
async readUnpublishedBranchFile(contentKey: string) {
try {
const {
branch,
collection,
slug,
path,
status,
newFile,
mediaFiles,
timeStamp,
} = await this.retrieveMetadata(contentKey);
const repoURL = this.useOpenAuthoring
? `/repos/${contentKey
.split('/')
.slice(0, 2)
.join('/')}`
: this.repoURL;
const fileData = (await this.readFile(path, null, { branch, repoURL })) as string;
return {
slug,
metaData: {
branch,
collection,
objects: { entry: { path, mediaFiles } },
status,
timeStamp,
},
fileData,
isModification: !newFile,
};
} catch (e) {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
}
}
filterOpenAuthoringBranches = async (branch: string) => { filterOpenAuthoringBranches = async (branch: string) => {
try { try {
const pullRequest = await this.getBranchPullRequest(branch); const pullRequest = await this.getBranchPullRequest(branch);
@ -1044,16 +983,17 @@ export default class API {
} }
} else { } else {
// Entry is already on editorial review workflow - commit to existing branch // Entry is already on editorial review workflow - commit to existing branch
const { files: diffs } = await this.getDifferences( const { files: diffFiles } = await this.getDifferences(
this.branch, this.branch,
await this.getHeadReference(branch), await this.getHeadReference(branch),
); );
const diffs = diffFiles.map(diffFromFile);
// mark media files to remove // mark media files to remove
const mediaFilesToRemove: { path: string; sha: string | null }[] = []; const mediaFilesToRemove: { path: string; sha: string | null }[] = [];
for (const diff of diffs) { for (const diff of diffs.filter(d => d.binary)) {
if (!mediaFilesList.some(file => file.path === diff.filename)) { if (!mediaFilesList.some(file => file.path === diff.path)) {
mediaFilesToRemove.push({ path: diff.filename, sha: null }); mediaFilesToRemove.push({ path: diff.path, sha: null });
} }
} }
@ -1414,30 +1354,67 @@ export default class API {
return Promise.resolve(Base64.encode(str)); return Promise.resolve(Base64.encode(str));
} }
uploadBlob(item: { raw?: string; sha?: string; toBase64?: () => Promise<string> }) { async uploadBlob(item: { raw?: string; sha?: string; toBase64?: () => Promise<string> }) {
const content = result(item, 'toBase64', partial(this.toBase64, item.raw as string)); const contentBase64 = await result(
item,
return content.then(contentBase64 => 'toBase64',
this.request(`${this.repoURL}/git/blobs`, { partial(this.toBase64, item.raw as string),
method: 'POST',
body: JSON.stringify({
content: contentBase64,
encoding: 'base64',
}),
}).then(response => {
item.sha = response.sha;
return item;
}),
); );
const response = await this.request(`${this.repoURL}/git/blobs`, {
method: 'POST',
body: JSON.stringify({
content: contentBase64,
encoding: 'base64',
}),
});
item.sha = response.sha;
return item;
} }
async updateTree(baseSha: string, files: { path: string; sha: string | null }[]) { async updateTree(
const tree: TreeEntry[] = files.map(file => ({ baseSha: string,
path: trimStart(file.path, '/'), files: { path: string; sha: string | null; newPath?: string }[],
mode: '100644', branch = this.branch,
type: 'blob', ) {
sha: file.sha, const toMove: { from: string; to: string; sha: string }[] = [];
})); const tree = files.reduce((acc, file) => {
const entry = {
path: trimStart(file.path, '/'),
mode: '100644',
type: 'blob',
sha: file.sha,
} as TreeEntry;
if (file.newPath) {
toMove.push({ from: file.path, to: file.newPath, sha: file.sha as string });
} else {
acc.push(entry);
}
return acc;
}, [] as TreeEntry[]);
for (const { from, to, sha } of toMove) {
const sourceDir = dirname(from);
const destDir = dirname(to);
const files = await this.listFiles(sourceDir, { branch, depth: 100 });
for (const file of files) {
// delete current path
tree.push({
path: file.path,
mode: '100644',
type: 'blob',
sha: null,
});
// create in new path
tree.push({
path: file.path.replace(sourceDir, destDir),
mode: '100644',
type: 'blob',
sha: file.path === from ? sha : file.id,
});
}
}
const newTree = await this.createTree(baseSha, tree); const newTree = await this.createTree(baseSha, tree);
return { ...newTree, parentSha: baseSha }; return { ...newTree, parentSha: baseSha };

View File

@ -403,6 +403,9 @@ export default class GraphQLAPI extends API {
...this.getBranchQuery(branch, this.repoOwner, this.repoName), ...this.getBranchQuery(branch, this.repoOwner, this.repoName),
fetchPolicy: CACHE_FIRST, fetchPolicy: CACHE_FIRST,
}); });
if (!data.repository.branch) {
throw new APIError('Branch not found', 404, API_NAME);
}
return data.repository.branch; return data.repository.branch;
} }
@ -539,12 +542,9 @@ export default class GraphQLAPI extends API {
try { try {
const contentKey = this.generateContentKey(collectionName, slug); const contentKey = this.generateContentKey(collectionName, slug);
const branchName = branchFromContentKey(contentKey); const branchName = branchFromContentKey(contentKey);
const metadata = await this.retrieveMetadata(contentKey); const pr = await this.getBranchPullRequest(branchName);
if (metadata.pullRequest.number !== MOCK_PULL_REQUEST) { if (pr.number !== MOCK_PULL_REQUEST) {
const { branch, pullRequest } = await this.getPullRequestAndBranch( const { branch, pullRequest } = await this.getPullRequestAndBranch(branchName, pr.number);
branchName,
metadata.pullRequest.number,
);
const { data } = await this.mutate({ const { data } = await this.mutate({
mutation: mutations.closePullRequestAndDeleteBranch, mutation: mutations.closePullRequestAndDeleteBranch,

View File

@ -132,48 +132,16 @@ describe('github backend implementation', () => {
}); });
}); });
describe('loadEntryMediaFiles', () => {
const readFile = jest.fn();
const mockAPI = {
readFile,
};
it('should return media files from meta data', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
const blob = new Blob(['']);
readFile.mockResolvedValue(blob);
const file = new File([blob], name);
await expect(
gitHubImplementation.loadEntryMediaFiles('branch', [
{ path: 'static/media/image.png', id: 'sha' },
]),
).resolves.toEqual([
{
id: 'sha',
displayURL: 'displayURL',
path: 'static/media/image.png',
name: 'image.png',
size: file.size,
file,
},
]);
});
});
describe('unpublishedEntry', () => { describe('unpublishedEntry', () => {
const generateContentKey = jest.fn(); const generateContentKey = jest.fn();
const readUnpublishedBranchFile = jest.fn(); const retrieveUnpublishedEntryData = jest.fn();
const mockAPI = { const mockAPI = {
generateContentKey, generateContentKey,
readUnpublishedBranchFile, retrieveUnpublishedEntryData,
}; };
it('should return unpublished entry', async () => { it('should return unpublished entry data', async () => {
const gitHubImplementation = new GitHubImplementation(config); const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI; gitHubImplementation.api = mockAPI;
gitHubImplementation.loadEntryMediaFiles = jest gitHubImplementation.loadEntryMediaFiles = jest
@ -183,37 +151,25 @@ describe('github backend implementation', () => {
generateContentKey.mockReturnValue('contentKey'); generateContentKey.mockReturnValue('contentKey');
const data = { const data = {
fileData: 'fileData', collection: 'collection',
isModification: true, slug: 'slug',
metaData: { status: 'draft',
branch: 'branch', diffs: [],
objects: { updatedAt: 'updatedAt',
entry: { path: 'entry-path', mediaFiles: [{ path: 'image.png', id: 'sha' }] },
},
},
}; };
readUnpublishedBranchFile.mockResolvedValue(data); retrieveUnpublishedEntryData.mockResolvedValue(data);
const collection = 'posts'; const collection = 'posts';
await expect(gitHubImplementation.unpublishedEntry(collection, 'slug')).resolves.toEqual({ const slug = 'slug';
slug: 'slug', await expect(gitHubImplementation.unpublishedEntry({ collection, slug })).resolves.toEqual(
file: { path: 'entry-path', id: null }, data,
data: 'fileData', );
metaData: data.metaData,
mediaFiles: [{ path: 'image.png', id: 'sha' }],
isModification: true,
});
expect(generateContentKey).toHaveBeenCalledTimes(1); expect(generateContentKey).toHaveBeenCalledTimes(1);
expect(generateContentKey).toHaveBeenCalledWith('posts', 'slug'); expect(generateContentKey).toHaveBeenCalledWith('posts', 'slug');
expect(readUnpublishedBranchFile).toHaveBeenCalledTimes(1); expect(retrieveUnpublishedEntryData).toHaveBeenCalledTimes(1);
expect(readUnpublishedBranchFile).toHaveBeenCalledWith('contentKey'); expect(retrieveUnpublishedEntryData).toHaveBeenCalledWith('contentKey');
expect(gitHubImplementation.loadEntryMediaFiles).toHaveBeenCalledTimes(1);
expect(gitHubImplementation.loadEntryMediaFiles).toHaveBeenCalledWith('branch', [
{ path: 'image.png', id: 'sha' },
]);
}); });
}); });

View File

@ -29,6 +29,7 @@ import {
blobToFileObj, blobToFileObj,
contentKeyFromBranch, contentKeyFromBranch,
unsentRequest, unsentRequest,
branchFromContentKey,
} from 'netlify-cms-lib-util'; } from 'netlify-cms-lib-util';
import AuthenticationPage from './AuthenticationPage'; import AuthenticationPage from './AuthenticationPage';
import { Octokit } from '@octokit/rest'; import { Octokit } from '@octokit/rest';
@ -546,68 +547,73 @@ export default class GitHub implements Implementation {
}; };
} }
loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) { async loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) {
const readFile = ( const readFile = (
path: string, path: string,
id: string | null | undefined, id: string | null | undefined,
{ parseText }: { parseText: boolean }, { parseText }: { parseText: boolean },
) => this.api!.readFile(path, id, { branch, parseText }); ) => this.api!.readFile(path, id, { branch, parseText });
return getMediaAsBlob(file.path, file.id, readFile).then(blob => { const blob = await getMediaAsBlob(file.path, file.id, readFile);
const name = basename(file.path); const name = basename(file.path);
const fileObj = blobToFileObj(name, blob); const fileObj = blobToFileObj(name, blob);
return { return {
id: file.id, id: file.id,
displayURL: URL.createObjectURL(fileObj), displayURL: URL.createObjectURL(fileObj),
path: file.path, path: file.path,
name, name,
size: fileObj.size, size: fileObj.size,
file: fileObj, file: fileObj,
}; };
});
} }
async loadEntryMediaFiles(branch: string, files: UnpublishedEntryMediaFile[]) { async unpublishedEntries() {
const mediaFiles = await Promise.all(files.map(file => this.loadMediaFile(branch, file)));
return mediaFiles;
}
unpublishedEntries() {
const listEntriesKeys = () => const listEntriesKeys = () =>
this.api!.listUnpublishedBranches().then(branches => this.api!.listUnpublishedBranches().then(branches =>
branches.map(branch => contentKeyFromBranch(branch)), branches.map(branch => contentKeyFromBranch(branch)),
); );
const readUnpublishedBranchFile = (contentKey: string) => const ids = await unpublishedEntries(listEntriesKeys);
this.api!.readUnpublishedBranchFile(contentKey); return ids;
return unpublishedEntries(listEntriesKeys, readUnpublishedBranchFile, 'GitHub');
} }
async unpublishedEntry( async unpublishedEntry({
collection: string, id,
slug: string, collection,
{ slug,
loadEntryMediaFiles = (branch: string, files: UnpublishedEntryMediaFile[]) => }: {
this.loadEntryMediaFiles(branch, files), id?: string;
} = {}, collection?: string;
) { slug?: string;
}) {
if (id) {
const data = await this.api!.retrieveUnpublishedEntryData(id);
return data;
} else if (collection && slug) {
const entryId = this.api!.generateContentKey(collection, slug);
const data = await this.api!.retrieveUnpublishedEntryData(entryId);
return data;
} else {
throw new Error('Missing unpublished entry id or collection and slug');
}
}
getBranch(collection: string, slug: string) {
const contentKey = this.api!.generateContentKey(collection, slug); const contentKey = this.api!.generateContentKey(collection, slug);
const data = await this.api!.readUnpublishedBranchFile(contentKey); const branch = branchFromContentKey(contentKey);
const files = data.metaData.objects.entry.mediaFiles || []; return branch;
const mediaFiles = await loadEntryMediaFiles( }
data.metaData.branch,
files.map(({ id, path }) => ({ id, path })), async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
); const branch = this.getBranch(collection, slug);
return { const data = (await this.api!.readFile(path, id, { branch })) as string;
slug, return data;
file: { path: data.metaData.objects.entry.path, id: null }, }
data: data.fileData as string,
metaData: data.metaData, async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
mediaFiles, const branch = this.getBranch(collection, slug);
isModification: data.isModification, const mediaFile = await this.loadMediaFile(branch, { path, id });
}; return mediaFile;
} }
async getDeployPreview(collection: string, slug: string) { async getDeployPreview(collection: string, slug: string) {

View File

@ -30,6 +30,7 @@ import {
import { Base64 } from 'js-base64'; import { Base64 } from 'js-base64';
import { Map } from 'immutable'; import { Map } from 'immutable';
import { flow, partial, result, trimStart } from 'lodash'; import { flow, partial, result, trimStart } from 'lodash';
import { dirname } from 'path';
export const API_NAME = 'GitLab'; export const API_NAME = 'GitLab';
@ -57,6 +58,7 @@ enum CommitAction {
type CommitItem = { type CommitItem = {
base64Content?: string; base64Content?: string;
path: string; path: string;
oldPath?: string;
action: CommitAction; action: CommitAction;
}; };
@ -68,6 +70,7 @@ interface CommitsParams {
actions?: { actions?: {
action: string; action: string;
file_path: string; file_path: string;
previous_path?: string;
content?: string; content?: string;
encoding?: string; encoding?: string;
}[]; }[];
@ -386,14 +389,14 @@ export default class API {
}; };
}; };
listAllFiles = async (path: string, recursive = false) => { listAllFiles = async (path: string, recursive = false, branch = this.branch) => {
const entries = []; const entries = [];
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({ let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({
url: `${this.repoURL}/repository/tree`, url: `${this.repoURL}/repository/tree`,
// Get the maximum number of entries per page // Get the maximum number of entries per page
// eslint-disable-next-line @typescript-eslint/camelcase // eslint-disable-next-line @typescript-eslint/camelcase
params: { path, ref: this.branch, per_page: 100, recursive }, params: { path, ref: branch, per_page: 100, recursive },
}); });
entries.push(...initialEntries); entries.push(...initialEntries);
while (cursor && cursor.actions!.has('next')) { while (cursor && cursor.actions!.has('next')) {
@ -423,7 +426,11 @@ export default class API {
action: item.action, action: item.action,
// eslint-disable-next-line @typescript-eslint/camelcase // eslint-disable-next-line @typescript-eslint/camelcase
file_path: item.path, file_path: item.path,
...(item.base64Content ? { content: item.base64Content, encoding: 'base64' } : {}), // eslint-disable-next-line @typescript-eslint/camelcase
...(item.oldPath ? { previous_path: item.oldPath } : {}),
...(item.base64Content !== undefined
? { content: item.base64Content, encoding: 'base64' }
: {}),
})); }));
const commitParams: CommitsParams = { const commitParams: CommitsParams = {
@ -459,21 +466,49 @@ export default class API {
} }
} }
async getCommitItems(files: (Entry | AssetProxy)[], branch: string) { async getCommitItems(files: { path: string; newPath?: string }[], branch: string) {
const items = await Promise.all( const items: CommitItem[] = await Promise.all(
files.map(async file => { files.map(async file => {
const [base64Content, fileExists] = await Promise.all([ const [base64Content, fileExists] = await Promise.all([
result(file, 'toBase64', partial(this.toBase64, (file as Entry).raw)), result(file, 'toBase64', partial(this.toBase64, (file as Entry).raw)),
this.isFileExists(file.path, branch), this.isFileExists(file.path, branch),
]); ]);
let action = CommitAction.CREATE;
let path = trimStart(file.path, '/');
let oldPath = undefined;
if (fileExists) {
action = file.newPath ? CommitAction.MOVE : CommitAction.UPDATE;
oldPath = file.newPath && path;
path = file.newPath ? trimStart(file.newPath, '/') : path;
}
return { return {
action: fileExists ? CommitAction.UPDATE : CommitAction.CREATE, action,
base64Content, base64Content,
path: trimStart(file.path, '/'), path,
oldPath,
}; };
}), }),
); );
return items as CommitItem[];
// move children
for (const item of items.filter(i => i.oldPath && i.action === CommitAction.MOVE)) {
const sourceDir = dirname(item.oldPath as string);
const destDir = dirname(item.path);
const children = await this.listAllFiles(sourceDir, true, branch);
children
.filter(f => f.path !== item.oldPath)
.forEach(file => {
items.push({
action: CommitAction.MOVE,
path: file.path.replace(sourceDir, destDir),
oldPath: file.path,
});
});
}
return items;
} }
async persistFiles(entry: Entry | null, mediaFiles: AssetProxy[], options: PersistOptions) { async persistFiles(entry: Entry | null, mediaFiles: AssetProxy[], options: PersistOptions) {
@ -604,54 +639,33 @@ export default class API {
oldPath: d.old_path, oldPath: d.old_path,
newPath: d.new_path, newPath: d.new_path,
newFile: d.new_file, newFile: d.new_file,
path: d.new_path || d.old_path,
binary: d.diff.startsWith('Binary') || /.svg$/.test(d.new_path), binary: d.diff.startsWith('Binary') || /.svg$/.test(d.new_path),
}; };
}); });
} }
async retrieveMetadata(contentKey: string) { async retrieveUnpublishedEntryData(contentKey: string) {
const { collection, slug } = parseContentKey(contentKey); const { collection, slug } = parseContentKey(contentKey);
const branch = branchFromContentKey(contentKey); const branch = branchFromContentKey(contentKey);
const mergeRequest = await this.getBranchMergeRequest(branch); const mergeRequest = await this.getBranchMergeRequest(branch);
const diff = await this.getDifferences(mergeRequest.sha); const diffs = await this.getDifferences(mergeRequest.sha);
const { oldPath: path, newFile: newFile } = diff.find(d => !d.binary) as { const diffsWithIds = await Promise.all(
oldPath: string; diffs.map(async d => {
newFile: boolean; const { path, newFile } = d;
}; const id = await this.getFileId(path, branch);
const mediaFiles = await Promise.all( return { id, path, newFile };
diff }),
.filter(d => d.oldPath !== path)
.map(async d => {
const path = d.newPath;
const id = await this.getFileId(path, branch);
return { path, id };
}),
); );
const label = mergeRequest.labels.find(isCMSLabel) as string; const label = mergeRequest.labels.find(isCMSLabel) as string;
const status = labelToStatus(label); const status = labelToStatus(label);
const timeStamp = mergeRequest.updated_at; const updatedAt = mergeRequest.updated_at;
return { branch, collection, slug, path, status, newFile, mediaFiles, timeStamp }; return {
}
async readUnpublishedBranchFile(contentKey: string) {
const {
branch,
collection, collection,
slug, slug,
path,
status, status,
newFile, diffs: diffsWithIds,
mediaFiles, updatedAt,
timeStamp,
} = await this.retrieveMetadata(contentKey);
const fileData = (await this.readFile(path, null, { branch })) as string;
return {
slug,
metaData: { branch, collection, objects: { entry: { path, mediaFiles } }, status, timeStamp },
fileData,
isModification: !newFile,
}; };
} }
@ -726,10 +740,9 @@ export default class API {
this.getCommitItems(files, branch), this.getCommitItems(files, branch),
this.getDifferences(branch), this.getDifferences(branch),
]); ]);
// mark files for deletion // mark files for deletion
for (const diff of diffs) { for (const diff of diffs.filter(d => d.binary)) {
if (!items.some(item => item.path === diff.newPath)) { if (!items.some(item => item.path === diff.path)) {
items.push({ action: CommitAction.DELETE, path: diff.newPath }); items.push({ action: CommitAction.DELETE, path: diff.newPath });
} }
} }

View File

@ -32,6 +32,7 @@ import {
localForage, localForage,
allEntriesByFolder, allEntriesByFolder,
filterByExtension, filterByExtension,
branchFromContentKey,
} from 'netlify-cms-lib-util'; } from 'netlify-cms-lib-util';
import AuthenticationPage from './AuthenticationPage'; import AuthenticationPage from './AuthenticationPage';
import API, { API_NAME } from './API'; import API, { API_NAME } from './API';
@ -351,34 +352,47 @@ export default class GitLab implements Implementation {
branches.map(branch => contentKeyFromBranch(branch)), branches.map(branch => contentKeyFromBranch(branch)),
); );
const readUnpublishedBranchFile = (contentKey: string) => const ids = await unpublishedEntries(listEntriesKeys);
this.api!.readUnpublishedBranchFile(contentKey); return ids;
return unpublishedEntries(listEntriesKeys, readUnpublishedBranchFile, API_NAME);
} }
async unpublishedEntry( async unpublishedEntry({
collection: string, id,
slug: string, collection,
{ slug,
loadEntryMediaFiles = (branch: string, files: UnpublishedEntryMediaFile[]) => }: {
this.loadEntryMediaFiles(branch, files), id?: string;
} = {}, collection?: string;
) { slug?: string;
}) {
if (id) {
const data = await this.api!.retrieveUnpublishedEntryData(id);
return data;
} else if (collection && slug) {
const entryId = generateContentKey(collection, slug);
const data = await this.api!.retrieveUnpublishedEntryData(entryId);
return data;
} else {
throw new Error('Missing unpublished entry id or collection and slug');
}
}
getBranch(collection: string, slug: string) {
const contentKey = generateContentKey(collection, slug); const contentKey = generateContentKey(collection, slug);
const data = await this.api!.readUnpublishedBranchFile(contentKey); const branch = branchFromContentKey(contentKey);
const mediaFiles = await loadEntryMediaFiles( return branch;
data.metaData.branch, }
data.metaData.objects.entry.mediaFiles,
); async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
return { const branch = this.getBranch(collection, slug);
slug, const data = (await this.api!.readFile(path, id, { branch })) as string;
file: { path: data.metaData.objects.entry.path, id: null }, return data;
data: data.fileData as string, }
metaData: data.metaData,
mediaFiles, async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
isModification: data.isModification, const branch = this.getBranch(collection, slug);
}; const mediaFile = await this.loadMediaFile(branch, { path, id });
return mediaFile;
} }
async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) { async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {

View File

@ -9,6 +9,7 @@ import {
EditorialWorkflowError, EditorialWorkflowError,
APIError, APIError,
unsentRequest, unsentRequest,
UnpublishedEntry,
} from 'netlify-cms-lib-util'; } from 'netlify-cms-lib-util';
import AuthenticationPage from './AuthenticationPage'; import AuthenticationPage from './AuthenticationPage';
@ -131,15 +132,22 @@ export default class ProxyBackend implements Implementation {
}); });
} }
async unpublishedEntry(collection: string, slug: string) { async unpublishedEntry({
id,
collection,
slug,
}: {
id?: string;
collection?: string;
slug?: string;
}) {
try { try {
const entry = await this.request({ const entry: UnpublishedEntry = await this.request({
action: 'unpublishedEntry', action: 'unpublishedEntry',
params: { branch: this.branch, collection, slug }, params: { branch: this.branch, id, collection, slug },
}); });
const mediaFiles = entry.mediaFiles.map(deserializeMediaFile); return entry;
return { ...entry, mediaFiles };
} catch (e) { } catch (e) {
if (e.status === 404) { if (e.status === 404) {
throw new EditorialWorkflowError('content is not under editorial workflow', true); throw new EditorialWorkflowError('content is not under editorial workflow', true);
@ -148,6 +156,22 @@ export default class ProxyBackend implements Implementation {
} }
} }
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
const { data } = await this.request({
action: 'unpublishedEntryDataFile',
params: { branch: this.branch, collection, slug, path, id },
});
return data;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const file = await this.request({
action: 'unpublishedEntryMediaFile',
params: { branch: this.branch, collection, slug, path, id },
});
return deserializeMediaFile(file);
}
deleteUnpublishedEntry(collection: string, slug: string) { deleteUnpublishedEntry(collection: string, slug: string) {
return this.request({ return this.request({
action: 'deleteUnpublishedEntry', action: 'deleteUnpublishedEntry',

View File

@ -1,4 +1,4 @@
import TestBackend, { getFolderEntries } from '../implementation'; import TestBackend, { getFolderFiles } from '../implementation';
describe('test backend implementation', () => { describe('test backend implementation', () => {
beforeEach(() => { beforeEach(() => {
@ -15,7 +15,7 @@ describe('test backend implementation', () => {
}, },
}; };
const backend = new TestBackend(); const backend = new TestBackend({});
await expect(backend.getEntry('posts/some-post.md')).resolves.toEqual({ await expect(backend.getEntry('posts/some-post.md')).resolves.toEqual({
file: { path: 'posts/some-post.md', id: null }, file: { path: 'posts/some-post.md', id: null },
@ -36,7 +36,7 @@ describe('test backend implementation', () => {
}, },
}; };
const backend = new TestBackend(); const backend = new TestBackend({});
await expect(backend.getEntry('posts/dir1/dir2/some-post.md')).resolves.toEqual({ await expect(backend.getEntry('posts/dir1/dir2/some-post.md')).resolves.toEqual({
file: { path: 'posts/dir1/dir2/some-post.md', id: null }, file: { path: 'posts/dir1/dir2/some-post.md', id: null },
@ -49,7 +49,7 @@ describe('test backend implementation', () => {
it('should persist entry', async () => { it('should persist entry', async () => {
window.repoFiles = {}; window.repoFiles = {};
const backend = new TestBackend(); const backend = new TestBackend({});
const entry = { path: 'posts/some-post.md', raw: 'content', slug: 'some-post.md' }; const entry = { path: 'posts/some-post.md', raw: 'content', slug: 'some-post.md' };
await backend.persistEntry(entry, [], { newEntry: true }); await backend.persistEntry(entry, [], { newEntry: true });
@ -58,6 +58,7 @@ describe('test backend implementation', () => {
posts: { posts: {
'some-post.md': { 'some-post.md': {
content: 'content', content: 'content',
path: 'posts/some-post.md',
}, },
}, },
}); });
@ -77,7 +78,7 @@ describe('test backend implementation', () => {
}, },
}; };
const backend = new TestBackend(); const backend = new TestBackend({});
const entry = { path: 'posts/new-post.md', raw: 'content', slug: 'new-post.md' }; const entry = { path: 'posts/new-post.md', raw: 'content', slug: 'new-post.md' };
await backend.persistEntry(entry, [], { newEntry: true }); await backend.persistEntry(entry, [], { newEntry: true });
@ -91,6 +92,7 @@ describe('test backend implementation', () => {
posts: { posts: {
'new-post.md': { 'new-post.md': {
content: 'content', content: 'content',
path: 'posts/new-post.md',
}, },
'other-post.md': { 'other-post.md': {
content: 'content', content: 'content',
@ -102,7 +104,7 @@ describe('test backend implementation', () => {
it('should persist nested entry', async () => { it('should persist nested entry', async () => {
window.repoFiles = {}; window.repoFiles = {};
const backend = new TestBackend(); const backend = new TestBackend({});
const slug = 'dir1/dir2/some-post.md'; const slug = 'dir1/dir2/some-post.md';
const path = `posts/${slug}`; const path = `posts/${slug}`;
@ -115,6 +117,7 @@ describe('test backend implementation', () => {
dir2: { dir2: {
'some-post.md': { 'some-post.md': {
content: 'content', content: 'content',
path: 'posts/dir1/dir2/some-post.md',
}, },
}, },
}, },
@ -136,7 +139,7 @@ describe('test backend implementation', () => {
}, },
}; };
const backend = new TestBackend(); const backend = new TestBackend({});
const slug = 'dir1/dir2/some-post.md'; const slug = 'dir1/dir2/some-post.md';
const path = `posts/${slug}`; const path = `posts/${slug}`;
@ -148,7 +151,7 @@ describe('test backend implementation', () => {
dir1: { dir1: {
dir2: { dir2: {
'some-post.md': { 'some-post.md': {
mediaFiles: ['file1'], path: 'posts/dir1/dir2/some-post.md',
content: 'new content', content: 'new content',
}, },
}, },
@ -168,7 +171,7 @@ describe('test backend implementation', () => {
}, },
}; };
const backend = new TestBackend(); const backend = new TestBackend({});
await backend.deleteFile('posts/some-post.md'); await backend.deleteFile('posts/some-post.md');
expect(window.repoFiles).toEqual({ expect(window.repoFiles).toEqual({
@ -189,7 +192,7 @@ describe('test backend implementation', () => {
}, },
}; };
const backend = new TestBackend(); const backend = new TestBackend({});
await backend.deleteFile('posts/dir1/dir2/some-post.md'); await backend.deleteFile('posts/dir1/dir2/some-post.md');
expect(window.repoFiles).toEqual({ expect(window.repoFiles).toEqual({
@ -202,7 +205,7 @@ describe('test backend implementation', () => {
}); });
}); });
describe('getFolderEntries', () => { describe('getFolderFiles', () => {
it('should get files by depth', () => { it('should get files by depth', () => {
const tree = { const tree = {
pages: { pages: {
@ -222,34 +225,34 @@ describe('test backend implementation', () => {
}, },
}; };
expect(getFolderEntries(tree, 'pages', 'md', 1)).toEqual([ expect(getFolderFiles(tree, 'pages', 'md', 1)).toEqual([
{ {
file: { path: 'pages/root-page.md', id: null }, path: 'pages/root-page.md',
data: 'root page content', content: 'root page content',
}, },
]); ]);
expect(getFolderEntries(tree, 'pages', 'md', 2)).toEqual([ expect(getFolderFiles(tree, 'pages', 'md', 2)).toEqual([
{ {
file: { path: 'pages/dir1/nested-page-1.md', id: null }, path: 'pages/dir1/nested-page-1.md',
data: 'nested page 1 content', content: 'nested page 1 content',
}, },
{ {
file: { path: 'pages/root-page.md', id: null }, path: 'pages/root-page.md',
data: 'root page content', content: 'root page content',
}, },
]); ]);
expect(getFolderEntries(tree, 'pages', 'md', 3)).toEqual([ expect(getFolderFiles(tree, 'pages', 'md', 3)).toEqual([
{ {
file: { path: 'pages/dir1/dir2/nested-page-2.md', id: null }, path: 'pages/dir1/dir2/nested-page-2.md',
data: 'nested page 2 content', content: 'nested page 2 content',
}, },
{ {
file: { path: 'pages/dir1/nested-page-1.md', id: null }, path: 'pages/dir1/nested-page-1.md',
data: 'nested page 1 content', content: 'nested page 1 content',
}, },
{ {
file: { path: 'pages/root-page.md', id: null }, path: 'pages/root-page.md',
data: 'root page content', content: 'root page content',
}, },
]); ]);
}); });

View File

@ -10,35 +10,65 @@ import {
ImplementationEntry, ImplementationEntry,
AssetProxy, AssetProxy,
PersistOptions, PersistOptions,
ImplementationMediaFile,
User, User,
Config, Config,
ImplementationFile, ImplementationFile,
} from 'netlify-cms-lib-util'; } from 'netlify-cms-lib-util';
import { extname, dirname } from 'path';
import AuthenticationPage from './AuthenticationPage'; import AuthenticationPage from './AuthenticationPage';
type RepoFile = { file?: { path: string }; content: string }; type RepoFile = { path: string; content: string | AssetProxy };
type RepoTree = { [key: string]: RepoFile | RepoTree }; type RepoTree = { [key: string]: RepoFile | RepoTree };
type UnpublishedRepoEntry = {
slug: string;
collection: string;
status: string;
diffs: {
id: string;
originalPath?: string;
path: string;
newFile: boolean;
status: string;
content: string | AssetProxy;
}[];
updatedAt: string;
};
declare global { declare global {
interface Window { interface Window {
repoFiles: RepoTree; repoFiles: RepoTree;
repoFilesUnpublished: ImplementationEntry[]; repoFilesUnpublished: { [key: string]: UnpublishedRepoEntry };
} }
} }
window.repoFiles = window.repoFiles || {}; window.repoFiles = window.repoFiles || {};
window.repoFilesUnpublished = window.repoFilesUnpublished || []; window.repoFilesUnpublished = window.repoFilesUnpublished || [];
function getFile(path: string) { function getFile(path: string, tree: RepoTree) {
const segments = path.split('/'); const segments = path.split('/');
let obj: RepoTree = window.repoFiles; let obj: RepoTree = tree;
while (obj && segments.length) { while (obj && segments.length) {
obj = obj[segments.shift() as string] as RepoTree; obj = obj[segments.shift() as string] as RepoTree;
} }
return ((obj as unknown) as RepoFile) || {}; return ((obj as unknown) as RepoFile) || {};
} }
function writeFile(path: string, content: string | AssetProxy, tree: RepoTree) {
const segments = path.split('/');
let obj = tree;
while (segments.length > 1) {
const segment = segments.shift() as string;
obj[segment] = obj[segment] || {};
obj = obj[segment] as RepoTree;
}
(obj[segments.shift() as string] as RepoFile) = { content, path };
}
function deleteFile(path: string, tree: RepoTree) {
unset(tree, path.split('/'));
}
const pageSize = 10; const pageSize = 10;
const getCursor = ( const getCursor = (
@ -60,12 +90,12 @@ const getCursor = (
}); });
}; };
export const getFolderEntries = ( export const getFolderFiles = (
tree: RepoTree, tree: RepoTree,
folder: string, folder: string,
extension: string, extension: string,
depth: number, depth: number,
files = [] as ImplementationEntry[], files = [] as RepoFile[],
path = folder, path = folder,
) => { ) => {
if (depth <= 0) { if (depth <= 0) {
@ -73,15 +103,14 @@ export const getFolderEntries = (
} }
Object.keys(tree[folder] || {}).forEach(key => { Object.keys(tree[folder] || {}).forEach(key => {
if (key.endsWith(`.${extension}`)) { if (extname(key)) {
const file = (tree[folder] as RepoTree)[key] as RepoFile; const file = (tree[folder] as RepoTree)[key] as RepoFile;
files.unshift({ if (!extension || key.endsWith(`.${extension}`)) {
file: { path: `${path}/${key}`, id: null }, files.unshift({ content: file.content, path: `${path}/${key}` });
data: file.content, }
});
} else { } else {
const subTree = tree[folder] as RepoTree; const subTree = tree[folder] as RepoTree;
return getFolderEntries(subTree, key, extension, depth - 1, files, `${path}/${key}`); return getFolderFiles(subTree, key, extension, depth - 1, files, `${path}/${key}`);
} }
}); });
@ -89,12 +118,12 @@ export const getFolderEntries = (
}; };
export default class TestBackend implements Implementation { export default class TestBackend implements Implementation {
assets: ImplementationMediaFile[]; mediaFolder: string;
options: { initialWorkflowStatus?: string }; options: { initialWorkflowStatus?: string };
constructor(_config: Config, options = {}) { constructor(config: Config, options = {}) {
this.assets = [];
this.options = options; this.options = options;
this.mediaFolder = config.media_folder;
} }
isGitBackend() { isGitBackend() {
@ -149,14 +178,22 @@ export default class TestBackend implements Implementation {
return 0; return 0;
})(); })();
// TODO: stop assuming cursors are for collections // TODO: stop assuming cursors are for collections
const allEntries = getFolderEntries(window.repoFiles, folder, extension, depth); const allFiles = getFolderFiles(window.repoFiles, folder, extension, depth);
const allEntries = allFiles.map(f => ({
data: f.content as string,
file: { path: f.path, id: f.path },
}));
const entries = allEntries.slice(newIndex * pageSize, newIndex * pageSize + pageSize); const entries = allEntries.slice(newIndex * pageSize, newIndex * pageSize + pageSize);
const newCursor = getCursor(folder, extension, allEntries, newIndex, depth); const newCursor = getCursor(folder, extension, allEntries, newIndex, depth);
return Promise.resolve({ entries, cursor: newCursor }); return Promise.resolve({ entries, cursor: newCursor });
} }
entriesByFolder(folder: string, extension: string, depth: number) { entriesByFolder(folder: string, extension: string, depth: number) {
const entries = folder ? getFolderEntries(window.repoFiles, folder, extension, depth) : []; const files = folder ? getFolderFiles(window.repoFiles, folder, extension, depth) : [];
const entries = files.map(f => ({
data: f.content as string,
file: { path: f.path, id: f.path },
}));
const cursor = getCursor(folder, extension, entries, 0, depth); const cursor = getCursor(folder, extension, entries, 0, depth);
const ret = take(entries, pageSize); const ret = take(entries, pageSize);
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
@ -169,7 +206,7 @@ export default class TestBackend implements Implementation {
return Promise.all( return Promise.all(
files.map(file => ({ files.map(file => ({
file, file,
data: getFile(file.path).content, data: getFile(file.path, window.repoFiles).content as string,
})), })),
); );
} }
@ -177,133 +214,160 @@ export default class TestBackend implements Implementation {
getEntry(path: string) { getEntry(path: string) {
return Promise.resolve({ return Promise.resolve({
file: { path, id: null }, file: { path, id: null },
data: getFile(path).content, data: getFile(path, window.repoFiles).content as string,
}); });
} }
unpublishedEntries() { unpublishedEntries() {
return Promise.resolve(window.repoFilesUnpublished); return Promise.resolve(Object.keys(window.repoFilesUnpublished));
} }
getMediaFiles(entry: ImplementationEntry) { unpublishedEntry({ id, collection, slug }: { id?: string; collection?: string; slug?: string }) {
const mediaFiles = entry.mediaFiles!.map(file => ({ if (id) {
...file, const parts = id.split('/');
...this.normalizeAsset(file), collection = parts[0];
file: file.file as File, slug = parts[1];
})); }
return mediaFiles; const entry = window.repoFilesUnpublished[`${collection}/${slug}`];
}
unpublishedEntry(collection: string, slug: string) {
const entry = window.repoFilesUnpublished.find(
e => e.metaData!.collection === collection && e.slug === slug,
);
if (!entry) { if (!entry) {
return Promise.reject( return Promise.reject(
new EditorialWorkflowError('content is not under editorial workflow', true), new EditorialWorkflowError('content is not under editorial workflow', true),
); );
} }
entry.mediaFiles = this.getMediaFiles(entry);
return Promise.resolve(entry); return Promise.resolve(entry);
} }
async unpublishedEntryDataFile(collection: string, slug: string, path: string) {
const entry = window.repoFilesUnpublished[`${collection}/${slug}`];
const file = entry.diffs.find(d => d.path === path);
return file?.content as string;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string) {
const entry = window.repoFilesUnpublished[`${collection}/${slug}`];
const file = entry.diffs.find(d => d.path === path);
return this.normalizeAsset(file?.content as AssetProxy);
}
deleteUnpublishedEntry(collection: string, slug: string) { deleteUnpublishedEntry(collection: string, slug: string) {
const unpubStore = window.repoFilesUnpublished; delete window.repoFilesUnpublished[`${collection}/${slug}`];
const existingEntryIndex = unpubStore.findIndex(
e => e.metaData!.collection === collection && e.slug === slug,
);
unpubStore.splice(existingEntryIndex, 1);
return Promise.resolve(); return Promise.resolve();
} }
async addOrUpdateUnpublishedEntry(
key: string,
path: string,
newPath: string | undefined,
raw: string,
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,
});
assetProxies.forEach(a => {
const asset = this.normalizeAsset(a);
diffs.push({
id: asset.id,
path: asset.path,
newFile: true,
status: 'added',
content: asset,
});
});
window.repoFilesUnpublished[key] = {
slug,
collection,
status,
diffs,
updatedAt: new Date().toISOString(),
};
}
async persistEntry( async persistEntry(
{ path, raw, slug }: Entry, { path, raw, slug, newPath }: Entry,
assetProxies: AssetProxy[], assetProxies: AssetProxy[],
options: PersistOptions, options: PersistOptions,
) { ) {
if (options.useWorkflow) { if (options.useWorkflow) {
const unpubStore = window.repoFilesUnpublished; const key = `${options.collectionName}/${slug}`;
const currentEntry = window.repoFilesUnpublished[key];
const existingEntryIndex = unpubStore.findIndex(e => e.file.path === path); const status =
if (existingEntryIndex >= 0) { currentEntry?.status || options.status || (this.options.initialWorkflowStatus as string);
const unpubEntry = { this.addOrUpdateUnpublishedEntry(
...unpubStore[existingEntryIndex], key,
data: raw, path,
mediaFiles: assetProxies.map(this.normalizeAsset), newPath,
}; raw,
assetProxies,
unpubStore.splice(existingEntryIndex, 1, unpubEntry); slug,
} else { options.collectionName as string,
const unpubEntry = { status,
data: raw, );
file: {
path,
id: null,
},
metaData: {
collection: options.collectionName as string,
status: (options.status || this.options.initialWorkflowStatus) as string,
},
slug,
mediaFiles: assetProxies.map(this.normalizeAsset),
isModification: !isEmpty(getFile(path)),
};
unpubStore.push(unpubEntry);
}
return Promise.resolve(); return Promise.resolve();
} }
const newEntry = options.newEntry || false; writeFile(path, raw, window.repoFiles);
assetProxies.forEach(a => {
const segments = path.split('/'); writeFile(a.path, raw, window.repoFiles);
const entry = newEntry ? { content: raw } : { ...getFile(path), content: raw }; });
let obj = window.repoFiles;
while (segments.length > 1) {
const segment = segments.shift() as string;
obj[segment] = obj[segment] || {};
obj = obj[segment] as RepoTree;
}
(obj[segments.shift() as string] as RepoFile) = entry;
await Promise.all(assetProxies.map(file => this.persistMedia(file)));
return Promise.resolve(); return Promise.resolve();
} }
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) { updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
const unpubStore = window.repoFilesUnpublished; window.repoFilesUnpublished[`${collection}/${slug}`].status = newStatus;
const entryIndex = unpubStore.findIndex(
e => e.metaData!.collection === collection && e.slug === slug,
);
unpubStore[entryIndex]!.metaData!.status = newStatus;
return Promise.resolve(); return Promise.resolve();
} }
async publishUnpublishedEntry(collection: string, slug: string) { publishUnpublishedEntry(collection: string, slug: string) {
const unpubStore = window.repoFilesUnpublished; const key = `${collection}/${slug}`;
const unpubEntryIndex = unpubStore.findIndex( const unpubEntry = window.repoFilesUnpublished[key];
e => e.metaData!.collection === collection && e.slug === slug,
);
const unpubEntry = unpubStore[unpubEntryIndex];
const entry = {
raw: unpubEntry.data,
slug: unpubEntry.slug as string,
path: unpubEntry.file.path,
};
unpubStore.splice(unpubEntryIndex, 1);
await this.persistEntry(entry, unpubEntry.mediaFiles!, { commitMessage: '' }); delete window.repoFilesUnpublished[key];
const tree = window.repoFiles;
unpubEntry.diffs.forEach(d => {
if (d.originalPath && !d.newFile) {
const originalPath = d.originalPath;
const sourceDir = dirname(originalPath);
const destDir = dirname(d.path);
const toMove = getFolderFiles(tree, originalPath.split('/')[0], '', 100).filter(f =>
f.path.startsWith(sourceDir),
);
toMove.forEach(f => {
deleteFile(f.path, tree);
writeFile(f.path.replace(sourceDir, destDir), f.content, tree);
});
}
writeFile(d.path, d.content, tree);
});
return Promise.resolve();
} }
getMedia() { getMedia(mediaFolder = this.mediaFolder) {
return Promise.resolve(this.assets); const files = getFolderFiles(window.repoFiles, mediaFolder.split('/')[0], '', 100).filter(f =>
f.path.startsWith(mediaFolder),
);
const assets = files.map(f => this.normalizeAsset(f.content as AssetProxy));
return Promise.resolve(assets);
} }
async getMediaFile(path: string) { async getMediaFile(path: string) {
const asset = this.assets.find(asset => asset.path === path) as ImplementationMediaFile; const asset = getFile(path, window.repoFiles).content as AssetProxy;
const url = asset.url as string; const url = asset.toString();
const name = basename(path); const name = basename(path);
const blob = await fetch(url).then(res => res.blob()); const blob = await fetch(url).then(res => res.blob());
const fileObj = new File([blob], name); const fileObj = new File([blob], name);
@ -340,18 +404,13 @@ export default class TestBackend implements Implementation {
persistMedia(assetProxy: AssetProxy) { persistMedia(assetProxy: AssetProxy) {
const normalizedAsset = this.normalizeAsset(assetProxy); const normalizedAsset = this.normalizeAsset(assetProxy);
this.assets.push(normalizedAsset); writeFile(assetProxy.path, assetProxy, window.repoFiles);
return Promise.resolve(normalizedAsset); return Promise.resolve(normalizedAsset);
} }
deleteFile(path: string) { deleteFile(path: string) {
const assetIndex = this.assets.findIndex(asset => asset.path === path); deleteFile(path, window.repoFiles);
if (assetIndex > -1) {
this.assets.splice(assetIndex, 1);
} else {
unset(window.repoFiles, path.split('/'));
}
return Promise.resolve(); return Promise.resolve();
} }

View File

@ -5,7 +5,6 @@ import { Map, List, fromJS } from 'immutable';
jest.mock('Lib/registry'); jest.mock('Lib/registry');
jest.mock('netlify-cms-lib-util'); jest.mock('netlify-cms-lib-util');
jest.mock('Formats/formats');
jest.mock('../lib/urlHelper'); jest.mock('../lib/urlHelper');
describe('Backend', () => { describe('Backend', () => {
@ -179,7 +178,7 @@ describe('Backend', () => {
const slug = 'slug'; const slug = 'slug';
localForage.getItem.mockReturnValue({ localForage.getItem.mockReturnValue({
raw: 'content', raw: '---\ntitle: "Hello World"\n---\n',
}); });
const result = await backend.getLocalDraftBackup(collection, slug); const result = await backend.getLocalDraftBackup(collection, slug);
@ -192,11 +191,12 @@ describe('Backend', () => {
slug: 'slug', slug: 'slug',
path: '', path: '',
partial: false, partial: false,
raw: 'content', raw: '---\ntitle: "Hello World"\n---\n',
data: {}, data: { title: 'Hello World' },
meta: {},
label: null, label: null,
metaData: null,
isModification: null, isModification: null,
status: '',
updatedOn: '', updatedOn: '',
}, },
}); });
@ -218,7 +218,7 @@ describe('Backend', () => {
const slug = 'slug'; const slug = 'slug';
localForage.getItem.mockReturnValue({ localForage.getItem.mockReturnValue({
raw: 'content', raw: '---\ntitle: "Hello World"\n---\n',
mediaFiles: [{ id: '1' }], mediaFiles: [{ id: '1' }],
}); });
@ -232,11 +232,12 @@ describe('Backend', () => {
slug: 'slug', slug: 'slug',
path: '', path: '',
partial: false, partial: false,
raw: 'content', raw: '---\ntitle: "Hello World"\n---\n',
data: {}, data: { title: 'Hello World' },
meta: {},
label: null, label: null,
metaData: null,
isModification: null, isModification: null,
status: '',
updatedOn: '', updatedOn: '',
}, },
}); });
@ -343,22 +344,24 @@ describe('Backend', () => {
describe('unpublishedEntry', () => { describe('unpublishedEntry', () => {
it('should return unpublished entry', async () => { it('should return unpublished entry', async () => {
const unpublishedEntryResult = { const unpublishedEntryResult = {
file: { path: 'path' }, diffs: [{ path: 'src/posts/index.md', newFile: false }, { path: 'netlify.png' }],
isModification: true,
metaData: {},
mediaFiles: [{ id: '1' }],
data: 'content',
}; };
const implementation = { const implementation = {
init: jest.fn(() => implementation), init: jest.fn(() => implementation),
unpublishedEntry: jest.fn().mockResolvedValue(unpublishedEntryResult), unpublishedEntry: jest.fn().mockResolvedValue(unpublishedEntryResult),
unpublishedEntryDataFile: jest
.fn()
.mockResolvedValueOnce('---\ntitle: "Hello World"\n---\n'),
unpublishedEntryMediaFile: jest.fn().mockResolvedValueOnce({ id: '1' }),
}; };
const config = Map({ media_folder: 'static/images' }); const config = Map({ media_folder: 'static/images' });
const backend = new Backend(implementation, { config, backendName: 'github' }); const backend = new Backend(implementation, { config, backendName: 'github' });
const collection = Map({ const collection = fromJS({
name: 'posts', name: 'posts',
folder: 'src/posts',
fields: [],
}); });
const state = { const state = {
@ -374,14 +377,15 @@ describe('Backend', () => {
author: '', author: '',
collection: 'posts', collection: 'posts',
slug: '', slug: '',
path: 'path', path: 'src/posts/index.md',
partial: false, partial: false,
raw: 'content', raw: '---\ntitle: "Hello World"\n---\n',
data: {}, data: { title: 'Hello World' },
meta: { path: 'src/posts/index.md' },
label: null, label: null,
metaData: {},
isModification: true, isModification: true,
mediaFiles: [{ id: '1', draft: true }], mediaFiles: [{ id: '1', draft: true }],
status: '',
updatedOn: '', updatedOn: '',
}); });
}); });

View File

@ -5,6 +5,7 @@ import {
retrieveLocalBackup, retrieveLocalBackup,
persistLocalBackup, persistLocalBackup,
getMediaAssets, getMediaAssets,
validateMetaField,
} from '../entries'; } from '../entries';
import configureMockStore from 'redux-mock-store'; import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
@ -13,6 +14,8 @@ import AssetProxy from '../../valueObjects/AssetProxy';
jest.mock('coreSrc/backend'); jest.mock('coreSrc/backend');
jest.mock('netlify-cms-lib-util'); jest.mock('netlify-cms-lib-util');
jest.mock('../mediaLibrary'); jest.mock('../mediaLibrary');
jest.mock('../../reducers/entries');
jest.mock('../../reducers/entryDraft');
const middlewares = [thunk]; const middlewares = [thunk];
const mockStore = configureMockStore(middlewares); const mockStore = configureMockStore(middlewares);
@ -45,14 +48,15 @@ describe('entries', () => {
author: '', author: '',
collection: undefined, collection: undefined,
data: {}, data: {},
meta: {},
isModification: null, isModification: null,
label: null, label: null,
mediaFiles: [], mediaFiles: [],
metaData: null,
partial: false, partial: false,
path: '', path: '',
raw: '', raw: '',
slug: '', slug: '',
status: '',
updatedOn: '', updatedOn: '',
}, },
type: 'DRAFT_CREATE_EMPTY', type: 'DRAFT_CREATE_EMPTY',
@ -76,14 +80,15 @@ describe('entries', () => {
author: '', author: '',
collection: undefined, collection: undefined,
data: { title: 'title', boolean: true }, data: { title: 'title', boolean: true },
meta: {},
isModification: null, isModification: null,
label: null, label: null,
mediaFiles: [], mediaFiles: [],
metaData: null,
partial: false, partial: false,
path: '', path: '',
raw: '', raw: '',
slug: '', slug: '',
status: '',
updatedOn: '', updatedOn: '',
}, },
type: 'DRAFT_CREATE_EMPTY', type: 'DRAFT_CREATE_EMPTY',
@ -109,14 +114,15 @@ describe('entries', () => {
author: '', author: '',
collection: undefined, collection: undefined,
data: { title: '&lt;script&gt;alert(&#039;hello&#039;)&lt;/script&gt;' }, data: { title: '&lt;script&gt;alert(&#039;hello&#039;)&lt;/script&gt;' },
meta: {},
isModification: null, isModification: null,
label: null, label: null,
mediaFiles: [], mediaFiles: [],
metaData: null,
partial: false, partial: false,
path: '', path: '',
raw: '', raw: '',
slug: '', slug: '',
status: '',
updatedOn: '', updatedOn: '',
}, },
type: 'DRAFT_CREATE_EMPTY', type: 'DRAFT_CREATE_EMPTY',
@ -383,4 +389,170 @@ describe('entries', () => {
expect(getMediaAssets({ entry })).toEqual([new AssetProxy({ path: 'path2' })]); expect(getMediaAssets({ entry })).toEqual([new AssetProxy({ path: 'path2' })]);
}); });
}); });
describe('validateMetaField', () => {
const state = {
config: fromJS({
slug: {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
},
}),
entries: fromJS([]),
};
const collection = fromJS({
folder: 'folder',
type: 'folder_based_collection',
name: 'name',
});
const t = jest.fn((key, args) => ({ key, args }));
const { selectCustomPath } = require('../../reducers/entryDraft');
const { selectEntryByPath } = require('../../reducers/entries');
beforeEach(() => {
jest.clearAllMocks();
});
it('should not return error on non meta field', () => {
expect(validateMetaField(null, null, fromJS({}), null, t)).toEqual({ error: false });
});
it('should not return error on meta path field', () => {
expect(
validateMetaField(null, null, fromJS({ meta: true, name: 'other' }), null, t),
).toEqual({ error: false });
});
it('should return error on empty path', () => {
expect(validateMetaField(null, null, fromJS({ meta: true, name: 'path' }), null, t)).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: null },
},
type: 'CUSTOM',
},
});
expect(
validateMetaField(null, null, fromJS({ meta: true, name: 'path' }), undefined, t),
).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: undefined },
},
type: 'CUSTOM',
},
});
expect(validateMetaField(null, null, fromJS({ meta: true, name: 'path' }), '', t)).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: '' },
},
type: 'CUSTOM',
},
});
});
it('should return error on invalid path', () => {
expect(
validateMetaField(state, null, fromJS({ meta: true, name: 'path' }), 'invalid path', t),
).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: 'invalid path' },
},
type: 'CUSTOM',
},
});
});
it('should return error on existing path', () => {
selectCustomPath.mockReturnValue('existing-path');
selectEntryByPath.mockReturnValue(fromJS({ path: 'existing-path' }));
expect(
validateMetaField(
{
...state,
entryDraft: fromJS({
entry: {},
}),
},
collection,
fromJS({ meta: true, name: 'path' }),
'existing-path',
t,
),
).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.pathExists',
args: { path: 'existing-path' },
},
type: 'CUSTOM',
},
});
expect(selectCustomPath).toHaveBeenCalledTimes(1);
expect(selectCustomPath).toHaveBeenCalledWith(
collection,
fromJS({ entry: { meta: { path: 'existing-path' } } }),
);
expect(selectEntryByPath).toHaveBeenCalledTimes(1);
expect(selectEntryByPath).toHaveBeenCalledWith(
state.entries,
collection.get('name'),
'existing-path',
);
});
it('should not return error on non existing path for new entry', () => {
selectCustomPath.mockReturnValue('non-existing-path');
selectEntryByPath.mockReturnValue(undefined);
expect(
validateMetaField(
{
...state,
entryDraft: fromJS({
entry: {},
}),
},
collection,
fromJS({ meta: true, name: 'path' }),
'non-existing-path',
t,
),
).toEqual({
error: false,
});
});
it('should not return error when for existing entry', () => {
selectCustomPath.mockReturnValue('existing-path');
selectEntryByPath.mockReturnValue(fromJS({ path: 'existing-path' }));
expect(
validateMetaField(
{
...state,
entryDraft: fromJS({
entry: { path: 'existing-path' },
}),
},
collection,
fromJS({ meta: true, name: 'path' }),
'existing-path',
t,
),
).toEqual({
error: false,
});
});
});
}); });

View File

@ -1,6 +1,6 @@
import yaml from 'yaml'; import yaml from 'yaml';
import { Map, fromJS } from 'immutable'; import { Map, fromJS } from 'immutable';
import { trimStart, get, isPlainObject } from 'lodash'; import { trimStart, trim, get, isPlainObject } from 'lodash';
import { authenticateUser } from 'Actions/auth'; import { authenticateUser } from 'Actions/auth';
import * as publishModes from 'Constants/publishModes'; import * as publishModes from 'Constants/publishModes';
import { validateConfig } from 'Constants/configSchema'; import { validateConfig } from 'Constants/configSchema';
@ -82,11 +82,28 @@ export function applyDefaults(config) {
'fields', 'fields',
traverseFields(collection.get('fields'), setDefaultPublicFolder), traverseFields(collection.get('fields'), setDefaultPublicFolder),
); );
collection = collection.set('folder', trimStart(folder, '/')); collection = collection.set('folder', trim(folder, '/'));
if (collection.has('meta')) {
const fields = collection.get('fields');
const metaFields = [];
collection.get('meta').forEach((value, key) => {
const field = value.withMutations(map => {
map.set('name', key);
map.set('meta', true);
map.set('required', true);
});
metaFields.push(field);
});
collection = collection.set('fields', fromJS([]).concat(metaFields, fields));
} else {
collection = collection.set('meta', Map());
}
} }
const files = collection.get('files'); const files = collection.get('files');
if (files) { if (files) {
collection = collection.delete('nested');
collection = collection.delete('meta');
collection = collection.set( collection = collection.set(
'files', 'files',
files.map(file => { files.map(file => {

View File

@ -5,17 +5,24 @@ import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
import { ThunkDispatch } from 'redux-thunk'; import { ThunkDispatch } from 'redux-thunk';
import { Map, List } from 'immutable'; import { Map, List } from 'immutable';
import { serializeValues } from '../lib/serializeEntryValues'; import { serializeValues } from '../lib/serializeEntryValues';
import { currentBackend } from '../backend'; import { currentBackend, slugFromCustomPath } from '../backend';
import { import {
selectPublishedSlugs, selectPublishedSlugs,
selectUnpublishedSlugs, selectUnpublishedSlugs,
selectEntry, selectEntry,
selectUnpublishedEntry, selectUnpublishedEntry,
} from '../reducers'; } from '../reducers';
import { selectEditingDraft } from '../reducers/entries';
import { selectFields } from '../reducers/collections'; import { selectFields } from '../reducers/collections';
import { EDITORIAL_WORKFLOW, status, Status } from '../constants/publishModes'; import { EDITORIAL_WORKFLOW, status, Status } from '../constants/publishModes';
import { EDITORIAL_WORKFLOW_ERROR } from 'netlify-cms-lib-util'; import { EDITORIAL_WORKFLOW_ERROR } from 'netlify-cms-lib-util';
import { loadEntry, entryDeleted, getMediaAssets, createDraftFromEntry } from './entries'; import {
loadEntry,
entryDeleted,
getMediaAssets,
createDraftFromEntry,
loadEntries,
} from './entries';
import { createAssetProxy } from '../valueObjects/AssetProxy'; import { createAssetProxy } from '../valueObjects/AssetProxy';
import { addAssets } from './media'; import { addAssets } from './media';
import { loadMedia } from './mediaLibrary'; import { loadMedia } from './mediaLibrary';
@ -24,6 +31,7 @@ import ValidationErrorTypes from '../constants/validationErrorTypes';
import { Collection, EntryMap, State, Collections, EntryDraft, MediaFile } from '../types/redux'; import { Collection, EntryMap, State, Collections, EntryDraft, MediaFile } from '../types/redux';
import { AnyAction } from 'redux'; import { AnyAction } from 'redux';
import { EntryValue } from '../valueObjects/Entry'; import { EntryValue } from '../valueObjects/Entry';
import { navigateToEntry } from '../routing/history';
const { notifSend } = notifActions; const { notifSend } = notifActions;
@ -406,7 +414,10 @@ export function persistUnpublishedEntry(collection: Collection, existingUnpublis
}), }),
); );
dispatch(unpublishedEntryPersisted(collection, transactionID, newSlug)); dispatch(unpublishedEntryPersisted(collection, transactionID, newSlug));
if (!existingUnpublishedEntry) return dispatch(loadUnpublishedEntry(collection, newSlug)); if (entry.get('slug') !== newSlug) {
dispatch(loadUnpublishedEntry(collection, newSlug));
navigateToEntry(collection.get('name'), newSlug);
}
} catch (error) { } catch (error) {
dispatch( dispatch(
notifSend({ notifSend({
@ -506,40 +517,47 @@ export function deleteUnpublishedEntry(collection: string, slug: string) {
}; };
} }
export function publishUnpublishedEntry(collection: string, slug: string) { export function publishUnpublishedEntry(collectionName: string, slug: string) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => { return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState(); const state = getState();
const collections = state.collections; const collections = state.collections;
const backend = currentBackend(state.config); const backend = currentBackend(state.config);
const transactionID = uuid(); const transactionID = uuid();
const entry = selectUnpublishedEntry(state, collection, slug); const entry = selectUnpublishedEntry(state, collectionName, slug);
dispatch(unpublishedEntryPublishRequest(collection, slug, transactionID)); dispatch(unpublishedEntryPublishRequest(collectionName, slug, transactionID));
return backend try {
.publishUnpublishedEntry(entry) await backend.publishUnpublishedEntry(entry);
.then(() => { // re-load media after entry was published
// re-load media after entry was published dispatch(loadMedia());
dispatch(loadMedia()); dispatch(
dispatch( notifSend({
notifSend({ message: { key: 'ui.toast.entryPublished' },
message: { key: 'ui.toast.entryPublished' }, kind: 'success',
kind: 'success', dismissAfter: 4000,
dismissAfter: 4000, }),
}), );
); dispatch(unpublishedEntryPublished(collectionName, slug, transactionID));
const collection = collections.get(collectionName);
dispatch(unpublishedEntryPublished(collection, slug, transactionID)); if (collection.has('nested')) {
return dispatch(loadEntry(collections.get(collection), slug)); dispatch(loadEntries(collection));
}) const newSlug = slugFromCustomPath(collection, entry.get('path'));
.catch((error: Error) => { loadEntry(collection, newSlug);
dispatch( if (slug !== newSlug && selectEditingDraft(state.entryDraft)) {
notifSend({ navigateToEntry(collection.get('name'), newSlug);
message: { key: 'ui.toast.onFailToPublishEntry', details: error }, }
kind: 'danger', } else {
dismissAfter: 8000, return dispatch(loadEntry(collection, slug));
}), }
); } catch (error) {
dispatch(unpublishedEntryPublishError(collection, slug, transactionID)); dispatch(
}); notifSend({
message: { key: 'ui.toast.onFailToPublishEntry', details: error },
kind: 'danger',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntryPublishError(collectionName, slug, transactionID));
}
}; };
} }

View File

@ -26,7 +26,10 @@ import { ThunkDispatch } from 'redux-thunk';
import { AnyAction } from 'redux'; import { AnyAction } from 'redux';
import { waitForMediaLibraryToLoad, loadMedia } from './mediaLibrary'; import { waitForMediaLibraryToLoad, loadMedia } from './mediaLibrary';
import { waitUntil } from './waitUntil'; import { waitUntil } from './waitUntil';
import { selectIsFetching, selectEntriesSortFields } from '../reducers/entries'; import { selectIsFetching, selectEntriesSortFields, selectEntryByPath } from '../reducers/entries';
import { selectCustomPath } from '../reducers/entryDraft';
import { navigateToEntry } from '../routing/history';
import { getProcessSegment } from '../lib/formatters';
const { notifSend } = notifActions; const { notifSend } = notifActions;
@ -336,7 +339,7 @@ export function discardDraft() {
} }
export function changeDraftField( export function changeDraftField(
field: string, field: EntryField,
value: string, value: string,
metadata: Record<string, unknown>, metadata: Record<string, unknown>,
entries: EntryMap[], entries: EntryMap[],
@ -520,7 +523,10 @@ export function loadEntries(collection: Collection, page = 0) {
cursor: Cursor; cursor: Cursor;
pagination: number; pagination: number;
entries: EntryValue[]; entries: EntryValue[];
} = await provider.listEntries(collection, page); } = await (collection.has('nested')
? // nested collections require all entries to construct the tree
provider.listAllEntries(collection).then((entries: EntryValue[]) => ({ entries }))
: provider.listEntries(collection, page));
response = { response = {
...response, ...response,
// The only existing backend using the pagination system is the // The only existing backend using the pagination system is the
@ -647,7 +653,8 @@ export function createEmptyDraft(collection: Collection, search: string) {
}); });
const fields = collection.get('fields', List()); const fields = collection.get('fields', List());
const dataFields = createEmptyDraftData(fields); const dataFields = createEmptyDraftData(fields.filter(f => !f!.get('meta')).toList());
const metaFields = createEmptyDraftData(fields.filter(f => f!.get('meta') === true).toList());
const state = getState(); const state = getState();
const backend = currentBackend(state.config); const backend = currentBackend(state.config);
@ -659,6 +666,8 @@ export function createEmptyDraft(collection: Collection, search: string) {
let newEntry = createEntry(collection.get('name'), '', '', { let newEntry = createEntry(collection.get('name'), '', '', {
data: dataFields, data: dataFields,
mediaFiles: [], mediaFiles: [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
meta: metaFields as any,
}); });
newEntry = await backend.processEntry(state, collection, newEntry); newEntry = await backend.processEntry(state, collection, newEntry);
dispatch(emptyDraftCreated(newEntry)); dispatch(emptyDraftCreated(newEntry));
@ -791,7 +800,7 @@ export function persistEntry(collection: Collection) {
assetProxies, assetProxies,
usedSlugs, usedSlugs,
}) })
.then((slug: string) => { .then((newSlug: string) => {
dispatch( dispatch(
notifSend({ notifSend({
message: { message: {
@ -805,8 +814,14 @@ export function persistEntry(collection: Collection) {
if (assetProxies.length > 0) { if (assetProxies.length > 0) {
dispatch(loadMedia()); dispatch(loadMedia());
} }
dispatch(entryPersisted(collection, serializedEntry, slug)); dispatch(entryPersisted(collection, serializedEntry, newSlug));
if (serializedEntry.get('newRecord')) return dispatch(loadEntry(collection, slug)); if (collection.has('nested')) {
dispatch(loadEntries(collection));
}
if (entry.get('slug') !== newSlug) {
dispatch(loadEntry(collection, newSlug));
navigateToEntry(collection.get('name'), newSlug);
}
}) })
.catch((error: Error) => { .catch((error: Error) => {
console.error(error); console.error(error);
@ -852,3 +867,53 @@ export function deleteEntry(collection: Collection, slug: string) {
}); });
}; };
} }
const getPathError = (
path: string | undefined,
key: string,
t: (key: string, args: Record<string, unknown>) => string,
) => {
return {
error: {
type: ValidationErrorTypes.CUSTOM,
message: t(`editor.editorControlPane.widget.${key}`, {
path,
}),
},
};
};
export function validateMetaField(
state: State,
collection: Collection,
field: EntryField,
value: string | undefined,
t: (key: string, args: Record<string, unknown>) => string,
) {
if (field.get('meta') && field.get('name') === 'path') {
if (!value) {
return getPathError(value, 'invalidPath', t);
}
const sanitizedPath = (value as string)
.split('/')
.map(getProcessSegment(state.config.get('slug')))
.join('/');
if (value !== sanitizedPath) {
return getPathError(value, 'invalidPath', t);
}
const customPath = selectCustomPath(collection, fromJS({ entry: { meta: { path: value } } }));
const existingEntry = customPath
? selectEntryByPath(state.entries, collection.get('name'), customPath)
: undefined;
const existingEntryPath = existingEntry?.get('path');
const draftPath = state.entryDraft?.getIn(['entry', 'path']);
if (existingEntryPath && existingEntryPath !== draftPath) {
return getPathError(value, 'pathExists', t);
}
}
return { error: false };
}

View File

@ -1,4 +1,4 @@
import { attempt, flatten, isError, uniq } from 'lodash'; import { attempt, flatten, isError, uniq, trim, sortBy } from 'lodash';
import { List, Map, fromJS } from 'immutable'; import { List, Map, fromJS } from 'immutable';
import * as fuzzy from 'fuzzy'; import * as fuzzy from 'fuzzy';
import { resolveFormat } from './formats/formats'; import { resolveFormat } from './formats/formats';
@ -15,6 +15,7 @@ import {
selectInferedField, selectInferedField,
selectMediaFolders, selectMediaFolders,
selectFieldsComments, selectFieldsComments,
selectHasMetaPath,
} from './reducers/collections'; } from './reducers/collections';
import { createEntry, EntryValue } from './valueObjects/Entry'; import { createEntry, EntryValue } from './valueObjects/Entry';
import { sanitizeChar } from './lib/urlHelper'; import { sanitizeChar } from './lib/urlHelper';
@ -34,6 +35,7 @@ import {
Config as ImplementationConfig, Config as ImplementationConfig,
blobToFileObj, blobToFileObj,
} from 'netlify-cms-lib-util'; } from 'netlify-cms-lib-util';
import { basename, join, extname, dirname } from 'path';
import { status } from './constants/publishModes'; import { status } from './constants/publishModes';
import { stringTemplate } from 'netlify-cms-lib-widgets'; import { stringTemplate } from 'netlify-cms-lib-widgets';
import { import {
@ -49,6 +51,8 @@ import {
} from './types/redux'; } from './types/redux';
import AssetProxy from './valueObjects/AssetProxy'; import AssetProxy from './valueObjects/AssetProxy';
import { FOLDER, FILES } from './constants/collectionTypes'; import { FOLDER, FILES } from './constants/collectionTypes';
import { selectCustomPath } from './reducers/entryDraft';
import { UnpublishedEntry } from 'netlify-cms-lib-util/src/implementation';
const { extractTemplateVars, dateParsers } = stringTemplate; const { extractTemplateVars, dateParsers } = stringTemplate;
@ -103,6 +107,13 @@ const sortByScore = (a: fuzzy.FilterResult<EntryValue>, b: fuzzy.FilterResult<En
return 0; return 0;
}; };
export const slugFromCustomPath = (collection: Collection, customPath: string) => {
const folderPath = collection.get('folder', '') as string;
const entryPath = customPath.toLowerCase().replace(folderPath.toLowerCase(), '');
const slug = join(dirname(trim(entryPath, '/')), basename(entryPath, extname(customPath)));
return slug;
};
interface AuthStore { interface AuthStore {
retrieve: () => User; retrieve: () => User;
store: (user: User) => void; store: (user: User) => void;
@ -153,6 +164,14 @@ type Implementation = BackendImplementation & {
init: (config: ImplementationConfig, options: ImplementationInitOptions) => Implementation; init: (config: ImplementationConfig, options: ImplementationInitOptions) => Implementation;
}; };
const prepareMetaPath = (path: string, collection: Collection) => {
if (!selectHasMetaPath(collection)) {
return path;
}
const dir = dirname(path);
return dir.substr(collection.get('folder')!.length + 1) || '/';
};
export class Backend { export class Backend {
implementation: Implementation; implementation: Implementation;
backendName: string; backendName: string;
@ -261,12 +280,14 @@ export class Backend {
async entryExist(collection: Collection, path: string, slug: string, useWorkflow: boolean) { async entryExist(collection: Collection, path: string, slug: string, useWorkflow: boolean) {
const unpublishedEntry = const unpublishedEntry =
useWorkflow && useWorkflow &&
(await this.implementation.unpublishedEntry(collection.get('name'), slug).catch(error => { (await this.implementation
if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) { .unpublishedEntry({ collection: collection.get('name'), slug })
return Promise.resolve(false); .catch(error => {
} if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) {
return Promise.reject(error); return Promise.resolve(false);
})); }
return Promise.reject(error);
}));
if (unpublishedEntry) return unpublishedEntry; if (unpublishedEntry) return unpublishedEntry;
@ -285,9 +306,15 @@ export class Backend {
entryData: Map<string, unknown>, entryData: Map<string, unknown>,
config: Config, config: Config,
usedSlugs: List<string>, usedSlugs: List<string>,
customPath: string | undefined,
) { ) {
const slugConfig = config.get('slug'); const slugConfig = config.get('slug');
const slug: string = slugFormatter(collection, entryData, slugConfig); let slug: string;
if (customPath) {
slug = slugFromCustomPath(collection, customPath);
} else {
slug = slugFormatter(collection, entryData, slugConfig);
}
let i = 1; let i = 1;
let uniqueSlug = slug; let uniqueSlug = slug;
@ -334,12 +361,17 @@ export class Backend {
let listMethod: () => Promise<ImplementationEntry[]>; let listMethod: () => Promise<ImplementationEntry[]>;
const collectionType = collection.get('type'); const collectionType = collection.get('type');
if (collectionType === FOLDER) { if (collectionType === FOLDER) {
listMethod = () => listMethod = () => {
this.implementation.entriesByFolder( const depth =
collection.get('nested')?.get('depth') ||
getPathDepth(collection.get('path', '') as string);
return this.implementation.entriesByFolder(
collection.get('folder') as string, collection.get('folder') as string,
extension, extension,
getPathDepth(collection.get('path', '') as string), depth,
); );
};
} else if (collectionType === FILES) { } else if (collectionType === FILES) {
const files = collection const files = collection
.get('files')! .get('files')!
@ -379,12 +411,12 @@ export class Backend {
async listAllEntries(collection: Collection) { async listAllEntries(collection: Collection) {
if (collection.get('folder') && this.implementation.allEntriesByFolder) { if (collection.get('folder') && this.implementation.allEntriesByFolder) {
const extension = selectFolderEntryExtension(collection); const extension = selectFolderEntryExtension(collection);
const depth =
collection.get('nested')?.get('depth') ||
getPathDepth(collection.get('path', '') as string);
return this.implementation return this.implementation
.allEntriesByFolder( .allEntriesByFolder(collection.get('folder') as string, extension, depth)
collection.get('folder') as string,
extension,
getPathDepth(collection.get('path', '') as string),
)
.then(entries => this.processEntries(entries, collection)); .then(entries => this.processEntries(entries, collection));
} }
@ -491,7 +523,12 @@ export class Backend {
const label = selectFileEntryLabel(collection, slug); const label = selectFileEntryLabel(collection, slug);
const entry: EntryValue = this.entryWithFormat(collection)( const entry: EntryValue = this.entryWithFormat(collection)(
createEntry(collection.get('name'), slug, path, { raw, label, mediaFiles }), createEntry(collection.get('name'), slug, path, {
raw,
label,
mediaFiles,
meta: { path: prepareMetaPath(path, collection) },
}),
); );
return { entry }; return { entry };
@ -548,6 +585,7 @@ export class Backend {
raw: loadedEntry.data, raw: loadedEntry.data,
label, label,
mediaFiles: [], mediaFiles: [],
meta: { path: prepareMetaPath(loadedEntry.file.path, collection) },
}); });
entry = this.entryWithFormat(collection)(entry); entry = this.entryWithFormat(collection)(entry);
@ -586,35 +624,93 @@ export class Backend {
}; };
} }
unpublishedEntries(collections: Collections) { async processUnpublishedEntry(
return this.implementation.unpublishedEntries!() collection: Collection,
.then(entries => entryData: UnpublishedEntry,
entries.map(loadedEntry => { withMediaFiles: boolean,
const collectionName = loadedEntry.metaData!.collection; ) {
const { slug } = entryData;
let extension: string;
if (collection.get('type') === FILES) {
const file = collection.get('files')!.find(f => f?.get('name') === slug);
extension = extname(file.get('file'));
} 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) {
const nonDataFiles = entryData.diffs.filter(d => !d.path.endsWith(extension));
const files = await Promise.all(
nonDataFiles.map(f =>
this.implementation!.unpublishedEntryMediaFile(
collection.get('name'),
slug,
f.path,
f.id,
),
),
);
mediaFiles.push(...files.map(f => ({ ...f, draft: true })));
}
const 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;
}
async unpublishedEntries(collections: Collections) {
const ids = await this.implementation.unpublishedEntries!();
const entries = (
await Promise.all(
ids.map(async id => {
const entryData = await this.implementation.unpublishedEntry({ id });
const collectionName = entryData.collection;
const collection = collections.find(c => c.get('name') === collectionName); const collection = collections.find(c => c.get('name') === collectionName);
const entry = createEntry(collectionName, loadedEntry.slug, loadedEntry.file.path, { if (!collection) {
raw: loadedEntry.data, console.warn(`Missing collection '${collectionName}' for unpublished entry '${id}'`);
isModification: loadedEntry.isModification, return null;
label: collection && selectFileEntryLabel(collection, loadedEntry.slug!), }
}); const entry = await this.processUnpublishedEntry(collection, entryData, false);
entry.metaData = loadedEntry.metaData;
return entry; return entry;
}), }),
) )
.then(entries => ({ ).filter(Boolean) as EntryValue[];
pagination: 0,
entries: entries.reduce((acc, entry) => { return { pagination: 0, entries };
const collection = collections.get(entry.collection);
if (collection) {
acc.push(this.entryWithFormat(collection)(entry) as EntryValue);
} else {
console.warn(
`Missing collection '${entry.collection}' for entry with path '${entry.path}'`,
);
}
return acc;
}, [] as EntryValue[]),
}));
} }
async processEntry(state: State, collection: Collection, entry: EntryValue) { async processEntry(state: State, collection: Collection, entry: EntryValue) {
@ -633,19 +729,12 @@ export class Backend {
} }
async unpublishedEntry(state: State, collection: Collection, slug: string) { async unpublishedEntry(state: State, collection: Collection, slug: string) {
const loadedEntry = await this.implementation!.unpublishedEntry!( const entryData = await this.implementation!.unpublishedEntry!({
collection.get('name') as string, collection: collection.get('name') as string,
slug, slug,
);
let entry = createEntry(collection.get('name'), loadedEntry.slug, loadedEntry.file.path, {
raw: loadedEntry.data,
isModification: loadedEntry.isModification,
metaData: loadedEntry.metaData,
mediaFiles: loadedEntry.mediaFiles?.map(file => ({ ...file, draft: true })) || [],
}); });
entry = this.entryWithFormat(collection)(entry); let entry = await this.processUnpublishedEntry(collection, entryData, true);
entry = await this.processEntry(state, collection, entry); entry = await this.processEntry(state, collection, entry);
return entry; return entry;
} }
@ -738,12 +827,17 @@ export class Backend {
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
const useWorkflow = selectUseWorkflow(config);
let entryObj: { let entryObj: {
path: string; path: string;
slug: string; slug: string;
raw: string; raw: string;
newPath?: string;
}; };
const customPath = selectCustomPath(collection, entryDraft);
if (newEntry) { if (newEntry) {
if (!selectAllowNewEntries(collection)) { if (!selectAllowNewEntries(collection)) {
throw new Error('Not allowed to create new entries in this collection'); throw new Error('Not allowed to create new entries in this collection');
@ -753,9 +847,9 @@ export class Backend {
entryDraft.getIn(['entry', 'data']), entryDraft.getIn(['entry', 'data']),
config, config,
usedSlugs, usedSlugs,
customPath,
); );
const path = selectEntryPath(collection, slug) as string; const path = customPath || (selectEntryPath(collection, slug) as string);
entryObj = { entryObj = {
path, path,
slug, slug,
@ -775,12 +869,13 @@ export class Backend {
asset.path = newPath; asset.path = newPath;
}); });
} else { } else {
const path = entryDraft.getIn(['entry', 'path']);
const slug = entryDraft.getIn(['entry', 'slug']); const slug = entryDraft.getIn(['entry', 'slug']);
entryObj = { entryObj = {
path, path: entryDraft.getIn(['entry', 'path']),
slug, // for workflow entries we refresh the slug on publish
slug: customPath && !useWorkflow ? slugFromCustomPath(collection, customPath) : slug,
raw: this.entryToRaw(collection, entryDraft.get('entry')), raw: this.entryToRaw(collection, entryDraft.get('entry')),
newPath: customPath,
}; };
} }
@ -798,8 +893,6 @@ export class Backend {
user.useOpenAuthoring, user.useOpenAuthoring,
); );
const useWorkflow = selectUseWorkflow(config);
const collectionName = collection.get('name'); const collectionName = collection.get('name');
const updatedOptions = { unpublished, status }; const updatedOptions = { unpublished, status };

View File

@ -234,6 +234,11 @@ class App extends React.Component {
collections={collections} collections={collections}
render={props => <Collection {...props} isSearchResults isSingleSearchResult />} render={props => <Collection {...props} isSearchResults isSingleSearchResult />}
/> />
<RouteInCollection
collections={collections}
path="/collections/:name/filter/:filterTerm*"
render={props => <Collection {...props} />}
/>
<Route <Route
path="/search/:searchTerm" path="/search/:searchTerm"
render={props => <Collection {...props} isSearchResults />} render={props => <Collection {...props} isSearchResults />}

View File

@ -33,7 +33,7 @@ const SearchResultHeading = styled.h1`
${components.cardTopHeading}; ${components.cardTopHeading};
`; `;
class Collection extends React.Component { export class Collection extends React.Component {
static propTypes = { static propTypes = {
searchTerm: PropTypes.string, searchTerm: PropTypes.string,
collectionName: PropTypes.string, collectionName: PropTypes.string,
@ -51,8 +51,14 @@ class Collection extends React.Component {
}; };
renderEntriesCollection = () => { renderEntriesCollection = () => {
const { collection } = this.props; const { collection, filterTerm } = this.props;
return <EntriesCollection collection={collection} viewStyle={this.state.viewStyle} />; return (
<EntriesCollection
collection={collection}
viewStyle={this.state.viewStyle}
filterTerm={filterTerm}
/>
);
}; };
renderEntriesSearch = () => { renderEntriesSearch = () => {
@ -83,11 +89,19 @@ class Collection extends React.Component {
onSortClick, onSortClick,
sort, sort,
viewFilters, viewFilters,
filterTerm,
t, t,
onFilterClick, onFilterClick,
filter, filter,
} = this.props; } = this.props;
const newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : '';
let newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : '';
if (newEntryUrl && filterTerm) {
newEntryUrl = getNewEntryUrl(collectionName);
if (filterTerm) {
newEntryUrl = `${newEntryUrl}?path=${filterTerm}`;
}
}
const searchResultKey = const searchResultKey =
'collection.collectionTop.searchResults' + (isSingleSearchResult ? 'InCollection' : ''); 'collection.collectionTop.searchResults' + (isSingleSearchResult ? 'InCollection' : '');
@ -98,6 +112,7 @@ class Collection extends React.Component {
collections={collections} collections={collections}
collection={(!isSearchResults || isSingleSearchResult) && collection} collection={(!isSearchResults || isSingleSearchResult) && collection}
searchTerm={searchTerm} searchTerm={searchTerm}
filterTerm={filterTerm}
/> />
<CollectionMain> <CollectionMain>
{isSearchResults ? ( {isSearchResults ? (
@ -132,7 +147,7 @@ class Collection extends React.Component {
function mapStateToProps(state, ownProps) { function mapStateToProps(state, ownProps) {
const { collections } = state; const { collections } = state;
const { isSearchResults, match, t } = ownProps; const { isSearchResults, match, t } = ownProps;
const { name, searchTerm } = match.params; const { name, searchTerm = '', filterTerm = '' } = match.params;
const collection = name ? collections.get(name) : collections.first(); const collection = name ? collections.get(name) : collections.first();
const sort = selectEntriesSort(state.entries, collection.get('name')); const sort = selectEntriesSort(state.entries, collection.get('name'));
const sortableFields = selectSortableFields(collection, t); const sortableFields = selectSortableFields(collection, t);
@ -145,6 +160,7 @@ function mapStateToProps(state, ownProps) {
collectionName: name, collectionName: name,
isSearchResults, isSearchResults,
searchTerm, searchTerm,
filterTerm,
sort, sort,
sortableFields, sortableFields,
viewFilters, viewFilters,

View File

@ -12,7 +12,7 @@ import { selectEntries, selectEntriesLoaded, selectIsFetching } from '../../../r
import { selectCollectionEntriesCursor } from 'Reducers/cursors'; import { selectCollectionEntriesCursor } from 'Reducers/cursors';
import Entries from './Entries'; import Entries from './Entries';
class EntriesCollection extends React.Component { export class EntriesCollection extends React.Component {
static propTypes = { static propTypes = {
collection: ImmutablePropTypes.map.isRequired, collection: ImmutablePropTypes.map.isRequired,
page: PropTypes.number, page: PropTypes.number,
@ -62,11 +62,36 @@ class EntriesCollection extends React.Component {
} }
} }
export const filterNestedEntries = (path, collectionFolder, entries) => {
const filtered = entries.filter(e => {
const entryPath = e.get('path').substring(collectionFolder.length + 1);
if (!entryPath.startsWith(path)) {
return false;
}
// only show immediate children
if (path) {
// non root path
const trimmed = entryPath.substring(path.length + 1);
return trimmed.split('/').length === 2;
} else {
// root path
return entryPath.split('/').length <= 2;
}
});
return filtered;
};
function mapStateToProps(state, ownProps) { function mapStateToProps(state, ownProps) {
const { collection, viewStyle } = ownProps; const { collection, viewStyle, filterTerm } = ownProps;
const page = state.entries.getIn(['pages', collection.get('name'), 'page']); const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
const entries = selectEntries(state.entries, collection); let entries = selectEntries(state.entries, collection);
if (collection.has('nested')) {
const collectionFolder = collection.get('folder');
entries = filterNestedEntries(filterTerm || '', collectionFolder, entries);
}
const entriesLoaded = selectEntriesLoaded(state.entries, collection.get('name')); const entriesLoaded = selectEntriesLoaded(state.entries, collection.get('name'));
const isFetching = selectIsFetching(state.entries, collection.get('name')); const isFetching = selectIsFetching(state.entries, collection.get('name'));

View File

@ -0,0 +1,153 @@
import React from 'react';
import ConnectedEntriesCollection, {
EntriesCollection,
filterNestedEntries,
} from '../EntriesCollection';
import { render } from '@testing-library/react';
import { fromJS } from 'immutable';
import configureStore from 'redux-mock-store';
import { Provider } from 'react-redux';
jest.mock('../Entries', () => 'mock-entries');
const middlewares = [];
const mockStore = configureStore(middlewares);
const renderWithRedux = (component, { store } = {}) => {
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
}
return render(component, { wrapper: Wrapper });
};
const toEntriesState = (collection, entriesArray) => {
const entries = entriesArray.reduce(
(acc, entry) => {
acc.entities[`${collection.get('name')}.${entry.slug}`] = entry;
acc.pages[collection.get('name')].ids.push(entry.slug);
return acc;
},
{ pages: { [collection.get('name')]: { ids: [] } }, entities: {} },
);
return fromJS(entries);
};
describe('filterNestedEntries', () => {
it('should return only immediate children for non root path', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir1/dir2/index', path: 'src/pages/dir1/dir2/index.md', data: { title: 'File 2' } },
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
{ slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
];
const entries = fromJS(entriesArray);
expect(filterNestedEntries('dir3', 'src/pages', entries).toJS()).toEqual([
{ slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
]);
});
it('should return immediate children and root for root path', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir1/dir2/index', path: 'src/pages/dir1/dir2/index.md', data: { title: 'File 2' } },
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
{ slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
];
const entries = fromJS(entriesArray);
expect(filterNestedEntries('', 'src/pages', entries).toJS()).toEqual([
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
]);
});
});
describe('EntriesCollection', () => {
const collection = fromJS({ name: 'pages', label: 'Pages', folder: 'src/pages' });
const props = {
t: jest.fn(),
loadEntries: jest.fn(),
traverseCollectionCursor: jest.fn(),
isFetching: false,
cursor: {},
collection,
};
it('should render with entries', () => {
const entries = fromJS([{ slug: 'index' }]);
const { asFragment } = render(<EntriesCollection {...props} entries={entries} />);
expect(asFragment()).toMatchSnapshot();
});
it('should render connected component', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir2/index', path: 'src/pages/dir2/index.md', data: { title: 'File 2' } },
];
const store = mockStore({
entries: toEntriesState(collection, entriesArray),
cursors: fromJS({}),
});
const { asFragment } = renderWithRedux(<ConnectedEntriesCollection collection={collection} />, {
store,
});
expect(asFragment()).toMatchSnapshot();
});
it('should render show only immediate children for nested collection', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir1/dir2/index', path: 'src/pages/dir1/dir2/index.md', data: { title: 'File 2' } },
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
{ slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
];
const store = mockStore({
entries: toEntriesState(collection, entriesArray),
cursors: fromJS({}),
});
const { asFragment } = renderWithRedux(
<ConnectedEntriesCollection collection={collection.set('nested', fromJS({ depth: 10 }))} />,
{
store,
},
);
expect(asFragment()).toMatchSnapshot();
});
it('should render apply filter term for nested collections', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir1/dir2/index', path: 'src/pages/dir1/dir2/index.md', data: { title: 'File 2' } },
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
{ slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
];
const store = mockStore({
entries: toEntriesState(collection, entriesArray),
cursors: fromJS({}),
});
const { asFragment } = renderWithRedux(
<ConnectedEntriesCollection
collection={collection.set('nested', fromJS({ depth: 10 }))}
filterTerm="dir3/dir4"
/>,
{
store,
},
);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@ -0,0 +1,49 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EntriesCollection should render apply filter term for nested collections 1`] = `
<DocumentFragment>
<mock-entries
collectionname="Pages"
collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\", \\"nested\\": Map { \\"depth\\": 10 } }"
cursor="[object Object]"
entries="List []"
isfetching="false"
/>
</DocumentFragment>
`;
exports[`EntriesCollection should render connected component 1`] = `
<DocumentFragment>
<mock-entries
collectionname="Pages"
collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\" }"
cursor="[object Object]"
entries="List [ Map { \\"slug\\": \\"index\\", \\"path\\": \\"src/pages/index.md\\", \\"data\\": Map { \\"title\\": \\"Root\\" } }, Map { \\"slug\\": \\"dir1/index\\", \\"path\\": \\"src/pages/dir1/index.md\\", \\"data\\": Map { \\"title\\": \\"File 1\\" } }, Map { \\"slug\\": \\"dir2/index\\", \\"path\\": \\"src/pages/dir2/index.md\\", \\"data\\": Map { \\"title\\": \\"File 2\\" } } ]"
isfetching="false"
/>
</DocumentFragment>
`;
exports[`EntriesCollection should render show only immediate children for nested collection 1`] = `
<DocumentFragment>
<mock-entries
collectionname="Pages"
collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\", \\"nested\\": Map { \\"depth\\": 10 } }"
cursor="[object Object]"
entries="List [ Map { \\"slug\\": \\"index\\", \\"path\\": \\"src/pages/index.md\\", \\"data\\": Map { \\"title\\": \\"Root\\" } }, Map { \\"slug\\": \\"dir1/index\\", \\"path\\": \\"src/pages/dir1/index.md\\", \\"data\\": Map { \\"title\\": \\"File 1\\" } }, Map { \\"slug\\": \\"dir3/index\\", \\"path\\": \\"src/pages/dir3/index.md\\", \\"data\\": Map { \\"title\\": \\"File 3\\" } } ]"
isfetching="false"
/>
</DocumentFragment>
`;
exports[`EntriesCollection should render with entries 1`] = `
<DocumentFragment>
<mock-entries
collectionname="Pages"
collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\" }"
cursor="[object Object]"
entries="List [ Map { \\"slug\\": \\"index\\" } ]"
isfetching="false"
/>
</DocumentFragment>
`;

View File

@ -0,0 +1,308 @@
import React from 'react';
import { List } from 'immutable';
import { css } from '@emotion/core';
import styled from '@emotion/styled';
import { connect } from 'react-redux';
import { NavLink } from 'react-router-dom';
import { dirname, sep } from 'path';
import { stringTemplate } from 'netlify-cms-lib-widgets';
import { selectEntryCollectionTitle } from '../../reducers/collections';
import { selectEntries } from '../../reducers/entries';
import { Icon, colors, components } from 'netlify-cms-ui-default';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { sortBy } from 'lodash';
const { addFileTemplateFields } = stringTemplate;
const NodeTitleContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
`;
const NodeTitle = styled.div`
margin-right: 4px;
`;
const Caret = styled.div`
position: relative;
top: 2px;
`;
const CaretDown = styled(Caret)`
${components.caretDown};
color: currentColor;
`;
const CaretRight = styled(Caret)`
${components.caretRight};
color: currentColor;
left: 2px;
`;
const TreeNavLink = styled(NavLink)`
display: flex;
font-size: 14px;
font-weight: 500;
align-items: center;
padding: 8px;
padding-left: ${props => props.depth * 20 + 12}px;
border-left: 2px solid #fff;
${Icon} {
margin-right: 8px;
flex-shrink: 0;
}
${props => css`
&:hover,
&:active,
&.${props.activeClassName} {
color: ${colors.active};
background-color: ${colors.activeBackground};
border-left-color: #4863c6;
}
`};
`;
const getNodeTitle = node => {
const title = node.isRoot
? node.title
: node.children.find(c => !c.isDir && c.title)?.title || node.title;
return title;
};
const TreeNode = props => {
const { collection, treeData, depth = 0, onToggle } = props;
const collectionName = collection.get('name');
const sortedData = sortBy(treeData, getNodeTitle);
return sortedData.map(node => {
const leaf = node.children.length <= 1 && !node.children[0]?.isDir && depth > 0;
if (leaf) {
return null;
}
let to = `/collections/${collectionName}`;
if (depth > 0) {
to = `${to}/filter${node.path}`;
}
const title = getNodeTitle(node);
const hasChildren = depth === 0 || node.children.some(c => c.children.some(c => c.isDir));
return (
<React.Fragment key={node.path}>
<TreeNavLink
exact
to={to}
activeClassName="sidebar-active"
onClick={() => onToggle({ node, expanded: !node.expanded })}
depth={depth}
data-testid={node.path}
>
<Icon type="write" />
<NodeTitleContainer>
<NodeTitle>{title}</NodeTitle>
{hasChildren && (node.expanded ? <CaretDown /> : <CaretRight />)}
</NodeTitleContainer>
</TreeNavLink>
{node.expanded && (
<TreeNode
collection={collection}
depth={depth + 1}
treeData={node.children}
onToggle={onToggle}
/>
)}
</React.Fragment>
);
});
};
TreeNode.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
depth: PropTypes.number,
treeData: PropTypes.array.isRequired,
onToggle: PropTypes.func.isRequired,
};
export const walk = (treeData, callback) => {
const traverse = children => {
for (const child of children) {
callback(child);
traverse(child.children);
}
};
return traverse(treeData);
};
export const getTreeData = (collection, entries) => {
const collectionFolder = collection.get('folder');
const rootFolder = '/';
const entriesObj = entries
.toJS()
.map(e => ({ ...e, path: e.path.substring(collectionFolder.length) }));
const dirs = entriesObj.reduce((acc, entry) => {
let dir = dirname(entry.path);
while (!acc[dir] && dir && dir !== rootFolder) {
const parts = dir.split(sep);
acc[dir] = parts.pop();
dir = parts.length && parts.join(sep);
}
return acc;
}, {});
if (collection.getIn(['nested', 'summary'])) {
collection = collection.set('summary', collection.getIn(['nested', 'summary']));
} else {
collection = collection.delete('summary');
}
const flatData = [
{
title: collection.get('label'),
path: rootFolder,
isDir: true,
isRoot: true,
},
...Object.entries(dirs).map(([key, value]) => ({
title: value,
path: key,
isDir: true,
isRoot: false,
})),
...entriesObj.map((e, index) => {
let entryMap = entries.get(index);
entryMap = entryMap.set(
'data',
addFileTemplateFields(entryMap.get('path'), entryMap.get('data')),
);
const title = selectEntryCollectionTitle(collection, entryMap);
return {
...e,
title,
isDir: false,
isRoot: false,
};
}),
];
const parentsToChildren = flatData.reduce((acc, node) => {
const parent = node.path === rootFolder ? '' : dirname(node.path);
if (acc[parent]) {
acc[parent].push(node);
} else {
acc[parent] = [node];
}
return acc;
}, {});
const reducer = (acc, value) => {
const node = value;
let children = [];
if (parentsToChildren[node.path]) {
children = parentsToChildren[node.path].reduce(reducer, []);
}
acc.push({ ...node, children });
return acc;
};
const treeData = parentsToChildren[''].reduce(reducer, []);
return treeData;
};
export const updateNode = (treeData, node, callback) => {
let stop = false;
const updater = nodes => {
if (stop) {
return nodes;
}
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].path === node.path) {
nodes[i] = callback(node);
stop = true;
return nodes;
}
}
nodes.forEach(node => updater(node.children));
return nodes;
};
return updater([...treeData]);
};
export class NestedCollection extends React.Component {
static propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entries: ImmutablePropTypes.list.isRequired,
filterTerm: PropTypes.string,
};
constructor(props) {
super(props);
this.state = {
treeData: getTreeData(this.props.collection, this.props.entries),
selected: null,
useFilter: true,
};
}
componentDidUpdate(prevProps) {
const { collection, entries, filterTerm } = this.props;
if (
collection !== prevProps.collection ||
entries !== prevProps.entries ||
filterTerm !== prevProps.filterTerm
) {
const expanded = {};
walk(this.state.treeData, node => {
if (node.expanded) {
expanded[node.path] = true;
}
});
const treeData = getTreeData(collection, entries);
const path = `/${filterTerm}`;
walk(treeData, node => {
if (expanded[node.path] || (this.state.useFilter && path.startsWith(node.path))) {
node.expanded = true;
}
});
this.setState({ treeData });
}
}
onToggle = ({ node, expanded }) => {
if (!this.state.selected || this.state.selected.path === node.path || expanded) {
const treeData = updateNode(this.state.treeData, node, node => ({
...node,
expanded,
}));
this.setState({ treeData, selected: node, useFilter: false });
} else {
// don't collapse non selected nodes when clicked
this.setState({ selected: node, useFilter: false });
}
};
render() {
const { treeData } = this.state;
const { collection } = this.props;
return <TreeNode collection={collection} treeData={treeData} onToggle={this.onToggle} />;
}
}
function mapStateToProps(state, ownProps) {
const { collection } = ownProps;
const entries = selectEntries(state.entries, collection) || List();
return { entries };
}
export default connect(mapStateToProps, null)(NestedCollection);

View File

@ -8,6 +8,7 @@ import { NavLink } from 'react-router-dom';
import { Icon, components, colors } from 'netlify-cms-ui-default'; import { Icon, components, colors } from 'netlify-cms-ui-default';
import { searchCollections } from 'Actions/collections'; import { searchCollections } from 'Actions/collections';
import CollectionSearch from './CollectionSearch'; import CollectionSearch from './CollectionSearch';
import NestedCollection from './NestedCollection';
const styles = { const styles = {
sidebarNavLinkActive: css` sidebarNavLinkActive: css`
@ -64,23 +65,35 @@ const SidebarNavLink = styled(NavLink)`
`}; `};
`; `;
class Sidebar extends React.Component { export class Sidebar extends React.Component {
static propTypes = { static propTypes = {
collections: ImmutablePropTypes.orderedMap.isRequired, collections: ImmutablePropTypes.orderedMap.isRequired,
collection: ImmutablePropTypes.map, collection: ImmutablePropTypes.map,
searchTerm: PropTypes.string, searchTerm: PropTypes.string,
filterTerm: PropTypes.string,
t: PropTypes.func.isRequired, t: PropTypes.func.isRequired,
}; };
static defaultProps = { renderLink = (collection, filterTerm) => {
searchTerm: '',
};
renderLink = collection => {
const collectionName = collection.get('name'); const collectionName = collection.get('name');
if (collection.has('nested')) {
return (
<li key={collectionName}>
<NestedCollection
collection={collection}
filterTerm={filterTerm}
data-testid={collectionName}
/>
</li>
);
}
return ( return (
<li key={collectionName}> <li key={collectionName}>
<SidebarNavLink to={`/collections/${collectionName}`} activeClassName="sidebar-active"> <SidebarNavLink
to={`/collections/${collectionName}`}
activeClassName="sidebar-active"
data-testid={collectionName}
>
<Icon type="write" /> <Icon type="write" />
{collection.get('label')} {collection.get('label')}
</SidebarNavLink> </SidebarNavLink>
@ -89,7 +102,8 @@ class Sidebar extends React.Component {
}; };
render() { render() {
const { collections, collection, searchTerm, t } = this.props; const { collections, collection, searchTerm, t, filterTerm } = this.props;
return ( return (
<SidebarContainer> <SidebarContainer>
<SidebarHeading>{t('collection.sidebar.collections')}</SidebarHeading> <SidebarHeading>{t('collection.sidebar.collections')}</SidebarHeading>
@ -103,7 +117,7 @@ class Sidebar extends React.Component {
{collections {collections
.toList() .toList()
.filter(collection => collection.get('hide') !== true) .filter(collection => collection.get('hide') !== true)
.map(this.renderLink)} .map(collection => this.renderLink(collection, filterTerm))}
</SidebarNavList> </SidebarNavList>
</SidebarContainer> </SidebarContainer>
); );

View File

@ -0,0 +1,68 @@
import React from 'react';
import ConnectedCollection, { Collection } from '../Collection';
import { render } from '@testing-library/react';
import { fromJS } from 'immutable';
import configureStore from 'redux-mock-store';
import { Provider } from 'react-redux';
jest.mock('../Entries/EntriesCollection', () => 'mock-entries-collection');
jest.mock('../CollectionTop', () => 'mock-collection-top');
jest.mock('../CollectionControls', () => 'mock-collection-controls');
jest.mock('../Sidebar', () => 'mock-sidebar');
const middlewares = [];
const mockStore = configureStore(middlewares);
const renderWithRedux = (component, { store } = {}) => {
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
}
return render(component, { wrapper: Wrapper });
};
describe('Collection', () => {
const collection = fromJS({ name: 'pages', sortableFields: [], view_filters: [] });
const props = {
collections: fromJS([collection]).toOrderedMap(),
collection,
collectionName: collection.get('name'),
t: jest.fn(key => key),
onSortClick: jest.fn(),
};
it('should render with collection without create url', () => {
const { asFragment } = render(
<Collection {...props} collection={collection.set('create', false)} />,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render with collection with create url', () => {
const { asFragment } = render(
<Collection {...props} collection={collection.set('create', true)} />,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render with collection with create url and path', () => {
const { asFragment } = render(
<Collection {...props} collection={collection.set('create', true)} filterTerm="dir1/dir2" />,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render connected component', () => {
const store = mockStore({
collections: props.collections,
entries: fromJS({}),
});
const { asFragment } = renderWithRedux(<ConnectedCollection match={{ params: {} }} />, {
store,
});
expect(asFragment()).toMatchSnapshot();
});
});

View File

@ -0,0 +1,440 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import ConnectedNestedCollection, {
NestedCollection,
getTreeData,
walk,
updateNode,
} from '../NestedCollection';
import { render, fireEvent } from '@testing-library/react';
import { fromJS } from 'immutable';
import configureStore from 'redux-mock-store';
import { Provider } from 'react-redux';
jest.mock('netlify-cms-ui-default', () => {
const actual = jest.requireActual('netlify-cms-ui-default');
return {
...actual,
Icon: 'mocked-icon',
};
});
const middlewares = [];
const mockStore = configureStore(middlewares);
const renderWithRedux = (component, { store } = {}) => {
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
}
return render(component, { wrapper: Wrapper });
};
describe('NestedCollection', () => {
const collection = fromJS({
name: 'pages',
label: 'Pages',
folder: 'src/pages',
fields: [{ name: 'title', widget: 'string' }],
});
it('should render correctly with no entries', () => {
const entries = fromJS([]);
const { asFragment, getByTestId } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
expect(getByTestId('/')).toHaveTextContent('Pages');
expect(getByTestId('/')).toHaveAttribute('href', '/collections/pages');
expect(asFragment()).toMatchSnapshot();
});
it('should render correctly with nested entries', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/b/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/b/a/index.md', data: { title: 'File 4' } },
]);
const { asFragment, getByTestId } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
// expand the tree
fireEvent.click(getByTestId('/'));
expect(getByTestId('/a')).toHaveTextContent('File 1');
expect(getByTestId('/a')).toHaveAttribute('href', '/collections/pages/filter/a');
expect(getByTestId('/b')).toHaveTextContent('File 2');
expect(getByTestId('/b')).toHaveAttribute('href', '/collections/pages/filter/b');
expect(asFragment()).toMatchSnapshot();
});
it('should keep expanded nodes on re-render', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/b/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/b/a/index.md', data: { title: 'File 4' } },
]);
const { getByTestId, rerender } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
fireEvent.click(getByTestId('/'));
fireEvent.click(getByTestId('/a'));
expect(getByTestId('/a')).toHaveTextContent('File 1');
const newEntries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/b/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/b/a/index.md', data: { title: 'File 4' } },
{ path: 'src/pages/c/index.md', data: { title: 'File 5' } },
{ path: 'src/pages/c/a/index.md', data: { title: 'File 6' } },
]);
rerender(
<MemoryRouter>
<NestedCollection collection={collection} entries={newEntries} />
</MemoryRouter>,
);
expect(getByTestId('/a')).toHaveTextContent('File 1');
});
it('should expand nodes based on filterTerm', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/a/index.md', data: { title: 'File 3' } },
]);
const { getByTestId, queryByTestId, rerender } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
expect(queryByTestId('/a/a')).toBeNull();
rerender(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} filterTerm={'a/a'} />
</MemoryRouter>,
);
expect(getByTestId('/a/a')).toHaveTextContent('File 2');
});
it('should ignore filterTerm once a user toggles an node', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/a/index.md', data: { title: 'File 3' } },
]);
const { getByTestId, queryByTestId, rerender } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
rerender(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} filterTerm={'a/a'} />
</MemoryRouter>,
);
expect(getByTestId('/a/a')).toHaveTextContent('File 2');
fireEvent.click(getByTestId('/a'));
rerender(
<MemoryRouter>
<NestedCollection
collection={collection}
entries={fromJS(entries.toJS())}
filterTerm={'a/a'}
/>
</MemoryRouter>,
);
expect(queryByTestId('/a/a')).toBeNull();
});
it('should not collapse an unselected node when clicked', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/a/a/a/a/index.md', data: { title: 'File 4' } },
]);
const { getByTestId } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
fireEvent.click(getByTestId('/'));
fireEvent.click(getByTestId('/a'));
fireEvent.click(getByTestId('/a/a'));
expect(getByTestId('/a/a')).toHaveTextContent('File 2');
fireEvent.click(getByTestId('/a'));
expect(getByTestId('/a/a')).toHaveTextContent('File 2');
});
it('should collapse a selected node when clicked', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/a/a/a/a/index.md', data: { title: 'File 4' } },
]);
const { getByTestId, queryByTestId } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
fireEvent.click(getByTestId('/'));
fireEvent.click(getByTestId('/a'));
fireEvent.click(getByTestId('/a/a'));
expect(getByTestId('/a/a/a')).toHaveTextContent('File 3');
fireEvent.click(getByTestId('/a/a'));
expect(queryByTestId('/a/a/a')).toBeNull();
});
it('should render connected component', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'a/index', path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ slug: 'b/index', path: 'src/pages/b/index.md', data: { title: 'File 2' } },
{ slug: 'a/a/index', path: 'src/pages/a/a/index.md', data: { title: 'File 3' } },
{ slug: 'b/a/index', path: 'src/pages/b/a/index.md', data: { title: 'File 4' } },
];
const entries = entriesArray.reduce(
(acc, entry) => {
acc.entities[`${collection.get('name')}.${entry.slug}`] = entry;
acc.pages[collection.get('name')].ids.push(entry.slug);
return acc;
},
{ pages: { [collection.get('name')]: { ids: [] } }, entities: {} },
);
const store = mockStore({ entries: fromJS(entries) });
const { asFragment, getByTestId } = renderWithRedux(
<MemoryRouter>
<ConnectedNestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
{ store },
);
// expand the root
fireEvent.click(getByTestId('/'));
expect(getByTestId('/a')).toHaveTextContent('File 1');
expect(getByTestId('/a')).toHaveAttribute('href', '/collections/pages/filter/a');
expect(getByTestId('/b')).toHaveTextContent('File 2');
expect(getByTestId('/b')).toHaveAttribute('href', '/collections/pages/filter/b');
expect(asFragment()).toMatchSnapshot();
});
describe('getTreeData', () => {
it('should return nested tree data from entries', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/intro/index.md', data: { title: 'intro index' } },
{ path: 'src/pages/intro/category/index.md', data: { title: 'intro category index' } },
{ path: 'src/pages/compliance/index.md', data: { title: 'compliance index' } },
]);
const treeData = getTreeData(collection, entries);
expect(treeData).toEqual([
{
title: 'Pages',
path: '/',
isDir: true,
isRoot: true,
children: [
{
title: 'intro',
path: '/intro',
isDir: true,
isRoot: false,
children: [
{
title: 'category',
path: '/intro/category',
isDir: true,
isRoot: false,
children: [
{
path: '/intro/category/index.md',
data: { title: 'intro category index' },
title: 'intro category index',
isDir: false,
isRoot: false,
children: [],
},
],
},
{
path: '/intro/index.md',
data: { title: 'intro index' },
title: 'intro index',
isDir: false,
isRoot: false,
children: [],
},
],
},
{
title: 'compliance',
path: '/compliance',
isDir: true,
isRoot: false,
children: [
{
path: '/compliance/index.md',
data: { title: 'compliance index' },
title: 'compliance index',
isDir: false,
isRoot: false,
children: [],
},
],
},
{
path: '/index.md',
data: { title: 'Root' },
title: 'Root',
isDir: false,
isRoot: false,
children: [],
},
],
},
]);
});
it('should ignore collection summary', () => {
const entries = fromJS([{ path: 'src/pages/index.md', data: { title: 'Root' } }]);
const treeData = getTreeData(collection, entries);
expect(treeData).toEqual([
{
title: 'Pages',
path: '/',
isDir: true,
isRoot: true,
children: [
{
path: '/index.md',
data: { title: 'Root' },
title: 'Root',
isDir: false,
isRoot: false,
children: [],
},
],
},
]);
});
it('should use nested collection summary for title', () => {
const entries = fromJS([{ path: 'src/pages/index.md', data: { title: 'Root' } }]);
const treeData = getTreeData(
collection.setIn(['nested', 'summary'], '{{filename}}'),
entries,
);
expect(treeData).toEqual([
{
title: 'Pages',
path: '/',
isDir: true,
isRoot: true,
children: [
{
path: '/index.md',
data: { title: 'Root' },
title: 'index',
isDir: false,
isRoot: false,
children: [],
},
],
},
]);
});
});
describe('walk', () => {
it('should visit every tree node', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/dir1/index.md', data: { title: 'Dir1 File' } },
{ path: 'src/pages/dir2/index.md', data: { title: 'Dir2 File' } },
]);
const treeData = getTreeData(collection, entries);
const callback = jest.fn();
walk(treeData, callback);
expect(callback).toHaveBeenCalledTimes(6);
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/index.md' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/dir1' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/dir2' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/dir1/index.md' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/dir2/index.md' }));
});
});
describe('updateNode', () => {
it('should update node', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/dir1/index.md', data: { title: 'Dir1 File' } },
{ path: 'src/pages/dir2/index.md', data: { title: 'Dir2 File' } },
]);
const treeData = getTreeData(collection, entries);
expect(treeData[0].children[0].children[0].expanded).toBeUndefined();
const callback = jest.fn(node => ({ ...node, expanded: true }));
const node = { path: '/dir1/index.md' };
updateNode(treeData, node, callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(node);
expect(treeData[0].children[0].children[0].expanded).toEqual(true);
});
});
});

View File

@ -0,0 +1,74 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Sidebar } from '../Sidebar';
import { render } from '@testing-library/react';
import { fromJS } from 'immutable';
jest.mock('netlify-cms-ui-default', () => {
const actual = jest.requireActual('netlify-cms-ui-default');
return {
...actual,
Icon: 'mocked-icon',
};
});
jest.mock('../NestedCollection', () => 'nested-collection');
jest.mock('../CollectionSearch', () => 'collection-search');
jest.mock('Actions/collections');
describe('Sidebar', () => {
const props = {
searchTerm: '',
t: jest.fn(key => key),
};
it('should render sidebar with a simple collection', () => {
const collections = fromJS([{ name: 'posts', label: 'Posts' }]).toOrderedMap();
const { asFragment, getByTestId } = render(
<MemoryRouter>
<Sidebar {...props} collections={collections} />
</MemoryRouter>,
);
expect(getByTestId('posts')).toHaveTextContent('Posts');
expect(getByTestId('posts')).toHaveAttribute('href', '/collections/posts');
expect(asFragment()).toMatchSnapshot();
});
it('should not render a hidden collection', () => {
const collections = fromJS([{ name: 'posts', label: 'Posts', hide: true }]).toOrderedMap();
const { queryByTestId } = render(
<MemoryRouter>
<Sidebar {...props} collections={collections} />
</MemoryRouter>,
);
expect(queryByTestId('posts')).toBeNull();
});
it('should render sidebar with a nested collection', () => {
const collections = fromJS([
{ name: 'posts', label: 'Posts', nested: { depth: 10 } },
]).toOrderedMap();
const { asFragment } = render(
<MemoryRouter>
<Sidebar {...props} collections={collections} />
</MemoryRouter>,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render nested collection with filterTerm', () => {
const collections = fromJS([
{ name: 'posts', label: 'Posts', nested: { depth: 10 } },
]).toOrderedMap();
const { asFragment } = render(
<MemoryRouter>
<Sidebar {...props} collections={collections} filterTerm="dir1/dir2" />
</MemoryRouter>,
);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@ -0,0 +1,153 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Collection should render connected component 1`] = `
<DocumentFragment>
.emotion-2 {
margin: 28px 18px;
}
.emotion-0 {
padding-left: 280px;
}
<div
class="emotion-2 emotion-3"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [] }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [] } }"
filterterm=""
searchterm=""
/>
<main
class="emotion-0 emotion-1"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [] }"
newentryurl=""
/>
<mock-collection-controls
filter="Map {}"
sortablefields=""
viewfilters=""
viewstyle="VIEW_STYLE_LIST"
/>
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [] }"
filterterm=""
viewstyle="VIEW_STYLE_LIST"
/>
</main>
</div>
</DocumentFragment>
`;
exports[`Collection should render with collection with create url 1`] = `
<DocumentFragment>
.emotion-2 {
margin: 28px 18px;
}
.emotion-0 {
padding-left: 280px;
}
<div
class="emotion-2 emotion-3"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [] } }"
/>
<main
class="emotion-0 emotion-1"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
newentryurl="/collections/pages/new"
/>
<mock-collection-controls
viewstyle="VIEW_STYLE_LIST"
/>
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
viewstyle="VIEW_STYLE_LIST"
/>
</main>
</div>
</DocumentFragment>
`;
exports[`Collection should render with collection with create url and path 1`] = `
<DocumentFragment>
.emotion-2 {
margin: 28px 18px;
}
.emotion-0 {
padding-left: 280px;
}
<div
class="emotion-2 emotion-3"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [] } }"
filterterm="dir1/dir2"
/>
<main
class="emotion-0 emotion-1"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
newentryurl="/collections/pages/new?path=dir1/dir2"
/>
<mock-collection-controls
viewstyle="VIEW_STYLE_LIST"
/>
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
filterterm="dir1/dir2"
viewstyle="VIEW_STYLE_LIST"
/>
</main>
</div>
</DocumentFragment>
`;
exports[`Collection should render with collection without create url 1`] = `
<DocumentFragment>
.emotion-2 {
margin: 28px 18px;
}
.emotion-0 {
padding-left: 280px;
}
<div
class="emotion-2 emotion-3"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": false }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [] } }"
/>
<main
class="emotion-0 emotion-1"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": false }"
newentryurl=""
/>
<mock-collection-controls
viewstyle="VIEW_STYLE_LIST"
/>
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": false }"
viewstyle="VIEW_STYLE_LIST"
/>
</main>
</div>
</DocumentFragment>
`;

View File

@ -0,0 +1,549 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NestedCollection should render connected component 1`] = `
<DocumentFragment>
.emotion-6 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 12px;
border-left: 2px solid #fff;
}
.emotion-6 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-6:hover,
.emotion-6:active,
.emotion-6.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
.emotion-4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-0 {
margin-right: 4px;
}
.emotion-2 {
position: relative;
top: 2px;
color: #fff;
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
border-top: 6px solid currentColor;
border-bottom: 0;
color: currentColor;
}
<a
class="emotion-6 emotion-7"
data-testid="/"
depth="0"
href="/collections/pages"
>
<mocked-icon
type="write"
/>
<div
class="emotion-4 emotion-5"
>
<div
class="emotion-0 emotion-1"
>
Pages
</div>
<div
class="emotion-2 emotion-3"
/>
</div>
</a>
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-0 {
margin-right: 4px;
}
.emotion-4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 32px;
border-left: 2px solid #fff;
}
.emotion-4 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-4:hover,
.emotion-4:active,
.emotion-4.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
<a
class="emotion-4 emotion-5"
data-testid="/a"
depth="1"
href="/collections/pages/filter/a"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-0 emotion-1"
>
File 1
</div>
</div>
</a>
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-0 {
margin-right: 4px;
}
.emotion-4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 32px;
border-left: 2px solid #fff;
}
.emotion-4 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-4:hover,
.emotion-4:active,
.emotion-4.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
<a
class="emotion-4 emotion-5"
data-testid="/b"
depth="1"
href="/collections/pages/filter/b"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-0 emotion-1"
>
File 2
</div>
</div>
</a>
</DocumentFragment>
`;
exports[`NestedCollection should render correctly with nested entries 1`] = `
<DocumentFragment>
.emotion-6 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 12px;
border-left: 2px solid #fff;
}
.emotion-6 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-6:hover,
.emotion-6:active,
.emotion-6.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
.emotion-4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-0 {
margin-right: 4px;
}
.emotion-2 {
position: relative;
top: 2px;
color: #fff;
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
border-top: 6px solid currentColor;
border-bottom: 0;
color: currentColor;
}
<a
aria-current="page"
class="emotion-6 emotion-7 sidebar-active"
data-testid="/"
depth="0"
href="/collections/pages"
>
<mocked-icon
type="write"
/>
<div
class="emotion-4 emotion-5"
>
<div
class="emotion-0 emotion-1"
>
Pages
</div>
<div
class="emotion-2 emotion-3"
/>
</div>
</a>
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-0 {
margin-right: 4px;
}
.emotion-4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 32px;
border-left: 2px solid #fff;
}
.emotion-4 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-4:hover,
.emotion-4:active,
.emotion-4.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
<a
class="emotion-4 emotion-5"
data-testid="/a"
depth="1"
href="/collections/pages/filter/a"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-0 emotion-1"
>
File 1
</div>
</div>
</a>
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-0 {
margin-right: 4px;
}
.emotion-4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 32px;
border-left: 2px solid #fff;
}
.emotion-4 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-4:hover,
.emotion-4:active,
.emotion-4.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
<a
class="emotion-4 emotion-5"
data-testid="/b"
depth="1"
href="/collections/pages/filter/b"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-0 emotion-1"
>
File 2
</div>
</div>
</a>
</DocumentFragment>
`;
exports[`NestedCollection should render correctly with no entries 1`] = `
<DocumentFragment>
.emotion-6 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 12px;
border-left: 2px solid #fff;
}
.emotion-6 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-6:hover,
.emotion-6:active,
.emotion-6.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
.emotion-4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-0 {
margin-right: 4px;
}
.emotion-2 {
position: relative;
top: 2px;
color: #fff;
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
border-left: 6px solid currentColor;
border-right: 0;
color: currentColor;
left: 2px;
}
<a
class="emotion-6 emotion-7"
data-testid="/"
depth="0"
href="/collections/pages"
>
<mocked-icon
type="write"
/>
<div
class="emotion-4 emotion-5"
>
<div
class="emotion-0 emotion-1"
>
Pages
</div>
<div
class="emotion-2 emotion-3"
/>
</div>
</a>
</DocumentFragment>
`;

View File

@ -0,0 +1,216 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sidebar should render nested collection with filterTerm 1`] = `
<DocumentFragment>
.emotion-4 {
box-shadow: 0 2px 6px 0 rgba(68,74,87,0.05),0 1px 3px 0 rgba(68,74,87,0.1);
border-radius: 5px;
background-color: #fff;
width: 250px;
padding: 8px 0 12px;
position: fixed;
max-height: calc(100vh - 112px);
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
.emotion-0 {
font-size: 23px;
font-weight: 600;
padding: 0;
margin: 18px 12px 12px;
color: #313d3e;
}
.emotion-2 {
margin: 16px 0 0;
list-style: none;
overflow: auto;
}
<aside
class="emotion-4 emotion-5"
>
<h2
class="emotion-0 emotion-1"
>
collection.sidebar.collections
</h2>
<collection-search
collections="OrderedMap { 0: Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\", \\"nested\\": Map { \\"depth\\": 10 } } }"
searchterm=""
/>
<ul
class="emotion-2 emotion-3"
>
<li>
<nested-collection
collection="Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\", \\"nested\\": Map { \\"depth\\": 10 } }"
data-testid="posts"
filterterm="dir1/dir2"
/>
</li>
</ul>
</aside>
</DocumentFragment>
`;
exports[`Sidebar should render sidebar with a nested collection 1`] = `
<DocumentFragment>
.emotion-4 {
box-shadow: 0 2px 6px 0 rgba(68,74,87,0.05),0 1px 3px 0 rgba(68,74,87,0.1);
border-radius: 5px;
background-color: #fff;
width: 250px;
padding: 8px 0 12px;
position: fixed;
max-height: calc(100vh - 112px);
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
.emotion-0 {
font-size: 23px;
font-weight: 600;
padding: 0;
margin: 18px 12px 12px;
color: #313d3e;
}
.emotion-2 {
margin: 16px 0 0;
list-style: none;
overflow: auto;
}
<aside
class="emotion-4 emotion-5"
>
<h2
class="emotion-0 emotion-1"
>
collection.sidebar.collections
</h2>
<collection-search
collections="OrderedMap { 0: Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\", \\"nested\\": Map { \\"depth\\": 10 } } }"
searchterm=""
/>
<ul
class="emotion-2 emotion-3"
>
<li>
<nested-collection
collection="Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\", \\"nested\\": Map { \\"depth\\": 10 } }"
data-testid="posts"
/>
</li>
</ul>
</aside>
</DocumentFragment>
`;
exports[`Sidebar should render sidebar with a simple collection 1`] = `
<DocumentFragment>
.emotion-6 {
box-shadow: 0 2px 6px 0 rgba(68,74,87,0.05),0 1px 3px 0 rgba(68,74,87,0.1);
border-radius: 5px;
background-color: #fff;
width: 250px;
padding: 8px 0 12px;
position: fixed;
max-height: calc(100vh - 112px);
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
.emotion-0 {
font-size: 23px;
font-weight: 600;
padding: 0;
margin: 18px 12px 12px;
color: #313d3e;
}
.emotion-4 {
margin: 16px 0 0;
list-style: none;
overflow: auto;
}
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px 12px;
border-left: 2px solid #fff;
z-index: -1;
}
.emotion-2 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-2:hover,
.emotion-2:active,
.emotion-2.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
<aside
class="emotion-6 emotion-7"
>
<h2
class="emotion-0 emotion-1"
>
collection.sidebar.collections
</h2>
<collection-search
collections="OrderedMap { 0: Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\" } }"
searchterm=""
/>
<ul
class="emotion-4 emotion-5"
>
<li>
<a
class="emotion-2 emotion-3"
data-testid="posts"
href="/collections/posts"
>
<mocked-icon
type="write"
/>
Posts
</a>
</li>
</ul>
</aside>
</DocumentFragment>
`;

View File

@ -34,12 +34,7 @@ import { selectFields } from 'Reducers/collections';
import { status, EDITORIAL_WORKFLOW } from 'Constants/publishModes'; import { status, EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import EditorInterface from './EditorInterface'; import EditorInterface from './EditorInterface';
import withWorkflow from './withWorkflow'; import withWorkflow from './withWorkflow';
import { navigateToCollection, navigateToNewEntry } from '../../routing/history';
const navigateCollection = collectionPath => history.push(`/collections/${collectionPath}`);
const navigateToCollection = collectionName => navigateCollection(collectionName);
const navigateToNewEntry = collectionName => navigateCollection(`${collectionName}/new`);
const navigateToEntry = (collectionName, slug) =>
navigateCollection(`${collectionName}/entries/${slug}`);
export class Editor extends React.Component { export class Editor extends React.Component {
static propTypes = { static propTypes = {
@ -169,16 +164,6 @@ export class Editor extends React.Component {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
/**
* If the old slug is empty and the new slug is not, a new entry was just
* saved, and we need to update navigation to the correct url using the
* slug.
*/
const newSlug = this.props.entryDraft && this.props.entryDraft.getIn(['entry', 'slug']);
if (!prevProps.slug && newSlug && this.props.newEntry) {
navigateToEntry(prevProps.collection.get('name'), newSlug);
}
if (!prevProps.localBackup && this.props.localBackup) { if (!prevProps.localBackup && this.props.localBackup) {
const confirmLoadBackup = window.confirm(this.props.t('editor.editor.confirmLoadBackup')); const confirmLoadBackup = window.confirm(this.props.t('editor.editor.confirmLoadBackup'));
if (confirmLoadBackup) { if (confirmLoadBackup) {
@ -453,7 +438,7 @@ function mapStateToProps(state, ownProps) {
const collectionEntriesLoaded = !!entries.getIn(['pages', collectionName]); const collectionEntriesLoaded = !!entries.getIn(['pages', collectionName]);
const unPublishedEntry = selectUnpublishedEntry(state, collectionName, slug); const unPublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
const publishedEntry = selectEntry(state, collectionName, slug); const publishedEntry = selectEntry(state, collectionName, slug);
const currentStatus = unPublishedEntry && unPublishedEntry.getIn(['metaData', 'status']); const currentStatus = unPublishedEntry && unPublishedEntry.get('status');
const deployPreview = selectDeployPreview(state, collectionName, slug); const deployPreview = selectDeployPreview(state, collectionName, slug);
const localBackup = entryDraft.get('localBackup'); const localBackup = entryDraft.get('localBackup');
const draftKey = entryDraft.get('key'); const draftKey = entryDraft.get('key');

View File

@ -20,6 +20,7 @@ import {
removeMediaControl, removeMediaControl,
} from 'Actions/mediaLibrary'; } from 'Actions/mediaLibrary';
import Widget from './Widget'; import Widget from './Widget';
import { validateMetaField } from '../../../actions/entries';
/** /**
* This is a necessary bridge as we are still passing classnames to widgets * This is a necessary bridge as we are still passing classnames to widgets
@ -116,6 +117,8 @@ class EditorControl extends React.Component {
isEditorComponent: PropTypes.bool, isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool, isNewEditorComponent: PropTypes.bool,
parentIds: PropTypes.arrayOf(PropTypes.string), parentIds: PropTypes.arrayOf(PropTypes.string),
entry: ImmutablePropTypes.map.isRequired,
collection: ImmutablePropTypes.map.isRequired,
}; };
static defaultProps = { static defaultProps = {
@ -171,6 +174,7 @@ class EditorControl extends React.Component {
isNewEditorComponent, isNewEditorComponent,
parentIds, parentIds,
t, t,
validateMetaField,
} = this.props; } = this.props;
const widgetName = field.get('widget'); const widgetName = field.get('widget');
@ -248,7 +252,7 @@ class EditorControl extends React.Component {
value={value} value={value}
mediaPaths={mediaPaths} mediaPaths={mediaPaths}
metadata={metadata} metadata={metadata}
onChange={(newValue, newMetadata) => onChange(fieldName, newValue, newMetadata)} onChange={(newValue, newMetadata) => onChange(field, newValue, newMetadata)}
onValidate={onValidate && partial(onValidate, this.uniqueFieldId)} onValidate={onValidate && partial(onValidate, this.uniqueFieldId)}
onOpenMediaLibrary={openMediaLibrary} onOpenMediaLibrary={openMediaLibrary}
onClearMediaControl={clearMediaControl} onClearMediaControl={clearMediaControl}
@ -277,6 +281,7 @@ class EditorControl extends React.Component {
isNewEditorComponent={isNewEditorComponent} isNewEditorComponent={isNewEditorComponent}
parentIds={parentIds} parentIds={parentIds}
t={t} t={t}
validateMetaField={validateMetaField}
/> />
{fieldHint && ( {fieldHint && (
<ControlHint active={isSelected || this.state.styleActive} error={hasErrors}> <ControlHint active={isSelected || this.state.styleActive} error={hasErrors}>
@ -311,10 +316,11 @@ const mapStateToProps = state => {
isFetching: state.search.get('isFetching'), isFetching: state.search.get('isFetching'),
queryHits: state.search.get('queryHits'), queryHits: state.search.get('queryHits'),
config: state.config, config: state.config,
collection,
entry, entry,
collection,
isLoadingAsset, isLoadingAsset,
loadEntry, loadEntry,
validateMetaField: (field, value, t) => validateMetaField(state, collection, field, value, t),
}; };
}; };

View File

@ -50,21 +50,27 @@ export default class ControlPane extends React.Component {
return ( return (
<ControlPaneContainer> <ControlPaneContainer>
{fields.map((field, i) => {fields.map((field, i) => {
field.get('widget') === 'hidden' ? null : ( return field.get('widget') === 'hidden' ? null : (
<EditorControl <EditorControl
key={i} key={i}
field={field} field={field}
value={entry.getIn(['data', field.get('name')])} value={
field.get('meta')
? entry.getIn(['meta', field.get('name')])
: entry.getIn(['data', field.get('name')])
}
fieldsMetaData={fieldsMetaData} fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors} fieldsErrors={fieldsErrors}
onChange={onChange} onChange={onChange}
onValidate={onValidate} onValidate={onValidate}
processControlRef={this.controlRef.bind(this)} processControlRef={this.controlRef.bind(this)}
controlRef={this.controlRef} controlRef={this.controlRef}
entry={entry}
collection={collection}
/> />
), );
)} })}
</ControlPaneContainer> </ControlPaneContainer>
); );
} }

View File

@ -59,6 +59,7 @@ export default class Widget extends Component {
onValidateObject: PropTypes.func, onValidateObject: PropTypes.func,
isEditorComponent: PropTypes.bool, isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool, isNewEditorComponent: PropTypes.bool,
entry: ImmutablePropTypes.map.isRequired,
}; };
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
@ -104,8 +105,11 @@ export default class Widget extends Component {
const field = this.props.field; const field = this.props.field;
const errors = []; const errors = [];
const validations = [this.validatePresence, this.validatePattern]; const validations = [this.validatePresence, this.validatePattern];
if (field.get('meta')) {
validations.push(this.props.validateMetaField);
}
validations.forEach(func => { validations.forEach(func => {
const response = func(field, value); const response = func(field, value, this.props.t);
if (response.error) errors.push(response.error); if (response.error) errors.push(response.error);
}); });
if (skipWrapped) { if (skipWrapped) {
@ -114,6 +118,7 @@ export default class Widget extends Component {
const wrappedError = this.validateWrappedControl(field); const wrappedError = this.validateWrappedControl(field);
if (wrappedError.error) errors.push(wrappedError.error); if (wrappedError.error) errors.push(wrappedError.error);
} }
this.props.onValidate(errors); this.props.onValidate(errors);
}; };
@ -211,8 +216,8 @@ export default class Widget extends Component {
/** /**
* Change handler for fields that are nested within another field. * Change handler for fields that are nested within another field.
*/ */
onChangeObject = (fieldName, newValue, newMetadata) => { onChangeObject = (field, newValue, newMetadata) => {
const newObjectValue = this.getObjectValue().set(fieldName, newValue); const newObjectValue = this.getObjectValue().set(field.get('name'), newValue);
return this.props.onChange( return this.props.onChange(
newObjectValue, newObjectValue,
newMetadata && { [this.props.field.get('name')]: newMetadata }, newMetadata && { [this.props.field.get('name')]: newMetadata },

View File

@ -77,6 +77,9 @@ export class PreviewPane extends React.Component {
// custom preview templates, where the field object can't be passed in. // custom preview templates, where the field object can't be passed in.
let field = fields && fields.find(f => f.get('name') === name); let field = fields && fields.find(f => f.get('name') === name);
let value = values && values.get(field.get('name')); let value = values && values.get(field.get('name'));
if (field.get('meta')) {
value = this.props.entry.getIn(['meta', field.get('name')]);
}
const nestedFields = field.get('fields'); const nestedFields = field.get('fields');
const singleField = field.get('field'); const singleField = field.get('field');
const metadata = fieldsMetaData && fieldsMetaData.get(field.get('name'), Map()); const metadata = fieldsMetaData && fieldsMetaData.get(field.get('name'), Map());

View File

@ -5,7 +5,7 @@ import { css } from '@emotion/core';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { Map } from 'immutable'; import { Map } from 'immutable';
import { Link } from 'react-router-dom'; import history from 'Routing/history';
import { import {
Icon, Icon,
Dropdown, Dropdown,
@ -80,7 +80,7 @@ const ToolbarSubSectionLast = styled(ToolbarSubSectionFirst)`
justify-content: flex-end; justify-content: flex-end;
`; `;
const ToolbarSectionBackLink = styled(Link)` const ToolbarSectionBackLink = styled.a`
${styles.toolbarSection}; ${styles.toolbarSection};
border-right-width: 1px; border-right-width: 1px;
font-weight: normal; font-weight: normal;
@ -568,7 +568,15 @@ class EditorToolbar extends React.Component {
return ( return (
<ToolbarContainer> <ToolbarContainer>
<ToolbarSectionBackLink to={`/collections/${collection.get('name')}`}> <ToolbarSectionBackLink
onClick={() => {
if (history.length > 0) {
history.goBack();
} else {
history.push(`/collections/${collection.get('name')}`);
}
}}
>
<BackArrow></BackArrow> <BackArrow></BackArrow>
<div> <div>
<BackCollection> <BackCollection>

View File

@ -204,13 +204,13 @@ class WorkflowList extends React.Component {
return ( return (
<div> <div>
{entries.map(entry => { {entries.map(entry => {
const timestamp = moment(entry.getIn(['metaData', 'timeStamp'])).format( const timestamp = moment(entry.get('updatedOn')).format(
t('workflow.workflow.dateFormat'), t('workflow.workflow.dateFormat'),
); );
const slug = entry.get('slug'); const slug = entry.get('slug');
const editLink = `collections/${entry.getIn(['metaData', 'collection'])}/entries/${slug}`; const collectionName = entry.get('collection');
const ownStatus = entry.getIn(['metaData', 'status']); const editLink = `collections/${collectionName}/entries/${slug}`;
const collectionName = entry.getIn(['metaData', 'collection']); const ownStatus = entry.get('status');
const collection = collections.find( const collection = collections.find(
collection => collection.get('name') === collectionName, collection => collection.get('name') === collectionName,
); );

View File

@ -316,5 +316,47 @@ describe('config', () => {
}).not.toThrow(); }).not.toThrow();
}); });
}); });
it('should throw if collection meta is not a plain object', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ meta: [] }] }));
}).toThrowError("'collections[0].meta' should be object");
});
it('should throw if collection meta is an empty object', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ meta: {} }] }));
}).toThrowError("'collections[0].meta' should NOT have fewer than 1 properties");
});
it('should throw if collection meta is an empty object', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ meta: { path: {} } }] }));
}).toThrowError("'collections[0].meta.path' should have required property 'label'");
expect(() => {
validateConfig(
merge({}, validConfig, { collections: [{ meta: { path: { label: 'Label' } } }] }),
);
}).toThrowError("'collections[0].meta.path' should have required property 'widget'");
expect(() => {
validateConfig(
merge({}, validConfig, {
collections: [{ meta: { path: { label: 'Label', widget: 'widget' } } }],
}),
);
}).toThrowError("'collections[0].meta.path' should have required property 'index_file'");
});
it('should allow collection meta to have a path configuration', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
collections: [
{ meta: { path: { label: 'Path', widget: 'string', index_file: 'index' } } },
],
}),
);
}).not.toThrow();
});
}); });
}); });

View File

@ -183,6 +183,30 @@ const getConfigSchema = () => ({
}, },
}, },
view_filters: viewFilters, view_filters: viewFilters,
nested: {
type: 'object',
properties: {
depth: { type: 'number', minimum: 1, maximum: 1000 },
summary: { type: 'string' },
},
required: ['depth'],
},
meta: {
type: 'object',
properties: {
path: {
type: 'object',
properties: {
label: { type: 'string' },
widget: { type: 'string' },
index_file: { type: 'string' },
},
required: ['label', 'widget', 'index_file'],
},
},
additionalProperties: false,
minProperties: 1,
},
}, },
required: ['name', 'label'], required: ['name', 'label'],
oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }], oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }],

View File

@ -103,7 +103,7 @@ export const prepareSlug = (slug: string) => {
); );
}; };
const getProcessSegment = (slugConfig: SlugConfig) => export const getProcessSegment = (slugConfig: SlugConfig) =>
flow([value => String(value), prepareSlug, partialRight(sanitizeSlug, slugConfig)]); flow([value => String(value), prepareSlug, partialRight(sanitizeSlug, slugConfig)]);
export const slugFormatter = ( export const slugFormatter = (

View File

@ -449,4 +449,13 @@ export const selectFieldsComments = (collection: Collection, entryMap: EntryMap)
return comments; return comments;
}; };
export const selectHasMetaPath = (collection: Collection) => {
return (
collection.has('folder') &&
collection.get('type') === FOLDER &&
collection.has('meta') &&
collection.get('meta')?.has('path')
);
};
export default collections; export default collections;

View File

@ -98,12 +98,7 @@ const unpublishedEntries = (state = Map(), action: EditorialWorkflowAction) => {
// Update Optimistically // Update Optimistically
return state.withMutations(map => { return state.withMutations(map => {
map.setIn( map.setIn(
[ ['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'status'],
'entities',
`${action.payload!.collection}.${action.payload!.slug}`,
'metaData',
'status',
],
action.payload!.newStatus, action.payload!.newStatus,
); );
map.setIn( map.setIn(
@ -148,7 +143,7 @@ export const selectUnpublishedEntry = (
export const selectUnpublishedEntriesByStatus = (state: EditorialWorkflow, status: string) => { export const selectUnpublishedEntriesByStatus = (state: EditorialWorkflow, status: string) => {
if (!state) return null; if (!state) return null;
const entities = state.get('entities') as Entities; const entities = state.get('entities') as Entities;
return entities.filter(entry => entry.getIn(['metaData', 'status']) === status).valueSeq(); return entities.filter(entry => entry.get('status') === status).valueSeq();
}; };
export const selectUnpublishedSlugs = (state: EditorialWorkflow, collection: string) => { export const selectUnpublishedSlugs = (state: EditorialWorkflow, collection: string) => {

View File

@ -351,6 +351,14 @@ export const selectEntries = (state: Entries, collection: Collection) => {
return entries; return entries;
}; };
export const selectEntryByPath = (state: Entries, collection: string, path: string) => {
const slugs = selectPublishedSlugs(state, collection);
const entries =
slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as List<EntryMap>);
return entries && entries.find(e => e?.get('path') === path);
};
export const selectEntriesLoaded = (state: Entries, collection: string) => { export const selectEntriesLoaded = (state: Entries, collection: string) => {
return !!state.getIn(['pages', collection]); return !!state.getIn(['pages', collection]);
}; };

View File

@ -22,6 +22,9 @@ import {
UNPUBLISHED_ENTRY_PERSIST_SUCCESS, UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
UNPUBLISHED_ENTRY_PERSIST_FAILURE, UNPUBLISHED_ENTRY_PERSIST_FAILURE,
} from 'Actions/editorialWorkflow'; } from 'Actions/editorialWorkflow';
import { get } from 'lodash';
import { selectFolderEntryExtension, selectHasMetaPath } from './collections';
import { join } from 'path';
const initialState = Map({ const initialState = Map({
entry: Map(), entry: Map(),
@ -87,10 +90,22 @@ const entryDraftReducer = (state = Map(), action) => {
} }
case DRAFT_CHANGE_FIELD: { case DRAFT_CHANGE_FIELD: {
return state.withMutations(state => { return state.withMutations(state => {
state.setIn(['entry', 'data', action.payload.field], action.payload.value); const { field, value, metadata, entries } = action.payload;
state.mergeDeepIn(['fieldsMetaData'], fromJS(action.payload.metadata)); const name = field.get('name');
const meta = field.get('meta');
if (meta) {
state.setIn(['entry', 'meta', name], value);
} else {
state.setIn(['entry', 'data', name], value);
}
state.mergeDeepIn(['fieldsMetaData'], fromJS(metadata));
const newData = state.getIn(['entry', 'data']); const newData = state.getIn(['entry', 'data']);
state.set('hasChanged', !action.payload.entries.some(e => newData.equals(e.get('data')))); const newMeta = state.getIn(['entry', 'meta']);
state.set(
'hasChanged',
!entries.some(e => newData.equals(e.get('data'))) ||
!entries.some(e => newMeta.equals(e.get('meta'))),
);
}); });
} }
case DRAFT_VALIDATION_ERRORS: case DRAFT_VALIDATION_ERRORS:
@ -161,4 +176,16 @@ const entryDraftReducer = (state = Map(), action) => {
} }
}; };
export const selectCustomPath = (collection, entryDraft) => {
if (!selectHasMetaPath(collection)) {
return;
}
const meta = entryDraft.getIn(['entry', 'meta']);
const path = meta && meta.get('path');
const indexFile = get(collection.toJS(), ['meta', 'path', 'index_file']);
const extension = selectFolderEntryExtension(collection);
const customPath = path && join(collection.get('folder'), path, `${indexFile}.${extension}`);
return customPath;
};
export default entryDraftReducer; export default entryDraftReducer;

View File

@ -0,0 +1,44 @@
jest.mock('history');
describe('history', () => {
const { createHashHistory } = require('history');
const history = { push: jest.fn(), replace: jest.fn() };
createHashHistory.mockReturnValue(history);
beforeEach(() => {
jest.clearAllMocks();
});
describe('navigateToCollection', () => {
it('should push route', () => {
const { navigateToCollection } = require('../history');
navigateToCollection('posts');
expect(history.push).toHaveBeenCalledTimes(1);
expect(history.push).toHaveBeenCalledWith('/collections/posts');
});
});
describe('navigateToNewEntry', () => {
it('should replace route', () => {
const { navigateToNewEntry } = require('../history');
navigateToNewEntry('posts');
expect(history.replace).toHaveBeenCalledTimes(1);
expect(history.replace).toHaveBeenCalledWith('/collections/posts/new');
});
});
describe('navigateToEntry', () => {
it('should replace route', () => {
const { navigateToEntry } = require('../history');
navigateToEntry('posts', 'index');
expect(history.replace).toHaveBeenCalledTimes(1);
expect(history.replace).toHaveBeenCalledWith('/collections/posts/entries/index');
});
});
});

View File

@ -2,4 +2,11 @@ import { createHashHistory } from 'history';
const history = createHashHistory(); const history = createHashHistory();
export const navigateToCollection = collectionName =>
history.push(`/collections/${collectionName}`);
export const navigateToNewEntry = collectionName =>
history.replace(`/collections/${collectionName}/new`);
export const navigateToEntry = (collectionName, slug) =>
history.replace(`/collections/${collectionName}/entries/${slug}`);
export default history; export default history;

View File

@ -93,9 +93,10 @@ export type EntryObject = {
collection: string; collection: string;
mediaFiles: List<MediaFileMap>; mediaFiles: List<MediaFileMap>;
newRecord: boolean; newRecord: boolean;
metaData: { status: string };
author?: string; author?: string;
updatedOn?: string; updatedOn?: string;
status: string;
meta: StaticallyTypedRecord<{ path: string }>;
}; };
export type EntryMap = StaticallyTypedRecord<EntryObject>; export type EntryMap = StaticallyTypedRecord<EntryObject>;
@ -107,6 +108,7 @@ export type FieldsErrors = StaticallyTypedRecord<{ [field: string]: { type: stri
export type EntryDraft = StaticallyTypedRecord<{ export type EntryDraft = StaticallyTypedRecord<{
entry: Entry; entry: Entry;
fieldsErrors: FieldsErrors; fieldsErrors: FieldsErrors;
fieldsMetaData?: Map<string, Map<string, string>>;
}>; }>;
export type EntryField = StaticallyTypedRecord<{ export type EntryField = StaticallyTypedRecord<{
@ -119,6 +121,7 @@ export type EntryField = StaticallyTypedRecord<{
media_folder?: string; media_folder?: string;
public_folder?: string; public_folder?: string;
comment?: string; comment?: string;
meta?: boolean;
}>; }>;
export type EntryFields = List<EntryField>; export type EntryFields = List<EntryField>;
@ -145,6 +148,17 @@ export type ViewFilter = {
pattern: string; pattern: string;
id: string; id: string;
}; };
type NestedObject = { depth: number };
type Nested = StaticallyTypedRecord<NestedObject>;
type PathObject = { label: string; widget: string; index_file: string };
type MetaObject = {
path?: StaticallyTypedRecord<PathObject>;
};
type Meta = StaticallyTypedRecord<MetaObject>;
type CollectionObject = { type CollectionObject = {
name: string; name: string;
@ -170,6 +184,8 @@ type CollectionObject = {
label: string; label: string;
sortableFields: List<string>; sortableFields: List<string>;
view_filters: List<StaticallyTypedRecord<ViewFilter>>; view_filters: List<StaticallyTypedRecord<ViewFilter>>;
nested?: Nested;
meta?: Meta;
}; };
export type Collection = StaticallyTypedRecord<CollectionObject>; export type Collection = StaticallyTypedRecord<CollectionObject>;
@ -332,6 +348,10 @@ export interface EntriesFilterFailurePayload {
error: Error; error: Error;
} }
export interface EntriesMoveSuccessPayload extends EntryPayload {
entries: EntryObject[];
}
export interface EntriesAction extends Action<string> { export interface EntriesAction extends Action<string> {
payload: payload:
| EntryRequestPayload | EntryRequestPayload

View File

@ -26,6 +26,9 @@ export default class AssetProxy {
async toBase64(): Promise<string> { async toBase64(): Promise<string> {
const blob = await fetch(this.url).then(response => response.blob()); const blob = await fetch(this.url).then(response => response.blob());
if (blob.size <= 0) {
return '';
}
const result = await new Promise<string>(resolve => { const result = await new Promise<string>(resolve => {
const fr = new FileReader(); const fr = new FileReader();
fr.onload = (readerEvt): void => { fr.onload = (readerEvt): void => {

View File

@ -7,11 +7,12 @@ interface Options {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
data?: any; data?: any;
label?: string | null; label?: string | null;
metaData?: unknown | null;
isModification?: boolean | null; isModification?: boolean | null;
mediaFiles?: MediaFile[] | null; mediaFiles?: MediaFile[] | null;
author?: string; author?: string;
updatedOn?: string; updatedOn?: string;
status?: string;
meta?: { path?: string };
} }
export interface EntryValue { export interface EntryValue {
@ -23,11 +24,12 @@ export interface EntryValue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any; data: any;
label: string | null; label: string | null;
metaData: unknown | null;
isModification: boolean | null; isModification: boolean | null;
mediaFiles: MediaFile[]; mediaFiles: MediaFile[];
author: string; author: string;
updatedOn: string; updatedOn: string;
status?: string;
meta: { path?: string };
} }
export function createEntry(collection: string, slug = '', path = '', options: Options = {}) { export function createEntry(collection: string, slug = '', path = '', options: Options = {}) {
@ -39,11 +41,12 @@ export function createEntry(collection: string, slug = '', path = '', options: O
raw: options.raw || '', raw: options.raw || '',
data: options.data || {}, data: options.data || {},
label: options.label || null, label: options.label || null,
metaData: options.metaData || null,
isModification: isBoolean(options.isModification) ? options.isModification : null, isModification: isBoolean(options.isModification) ? options.isModification : null,
mediaFiles: options.mediaFiles || [], mediaFiles: options.mediaFiles || [],
author: options.author || '', author: options.author || '',
updatedOn: options.updatedOn || '', updatedOn: options.updatedOn || '',
status: options.status || '',
meta: options.meta || {},
}; };
return returnObj; return returnObj;

View File

@ -26,13 +26,16 @@ export interface UnpublishedEntryMediaFile {
} }
export interface ImplementationEntry { export interface ImplementationEntry {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: string; data: string;
file: { path: string; label?: string; id?: string | null; author?: string; updatedOn?: string }; file: { path: string; label?: string; id?: string | null; author?: string; updatedOn?: string };
slug?: string; }
mediaFiles?: ImplementationMediaFile[];
metaData?: { collection: string; status: string }; export interface UnpublishedEntry {
isModification?: boolean; slug: string;
collection: string;
status: string;
diffs: { id: string; path: string; newFile: boolean }[];
updatedAt: string;
} }
export interface Map { export interface Map {
@ -48,7 +51,7 @@ export type AssetProxy = {
toBase64?: () => Promise<string>; toBase64?: () => Promise<string>;
}; };
export type Entry = { path: string; slug: string; raw: string }; export type Entry = { path: string; slug: string; raw: string; newPath?: string };
export type PersistOptions = { export type PersistOptions = {
newEntry?: boolean; newEntry?: boolean;
@ -116,8 +119,24 @@ export interface Implementation {
persistMedia: (file: AssetProxy, opts: PersistOptions) => Promise<ImplementationMediaFile>; persistMedia: (file: AssetProxy, opts: PersistOptions) => Promise<ImplementationMediaFile>;
deleteFile: (path: string, commitMessage: string) => Promise<void>; deleteFile: (path: string, commitMessage: string) => Promise<void>;
unpublishedEntries: () => Promise<ImplementationEntry[]>; unpublishedEntries: () => Promise<string[]>;
unpublishedEntry: (collection: string, slug: string) => Promise<ImplementationEntry>; unpublishedEntry: (args: {
id?: string;
collection?: string;
slug?: string;
}) => Promise<UnpublishedEntry>;
unpublishedEntryDataFile: (
collection: string,
slug: string,
path: string,
id: string,
) => Promise<string>;
unpublishedEntryMediaFile: (
collection: string,
slug: string,
path: string,
id: string,
) => Promise<ImplementationMediaFile>;
updateUnpublishedEntryStatus: ( updateUnpublishedEntryStatus: (
collection: string, collection: string,
slug: string, slug: string,
@ -155,12 +174,6 @@ export type ImplementationFile = {
path: string; path: string;
}; };
type Metadata = {
objects: { entry: { path: string } };
collection: string;
status: string;
};
type ReadFile = ( type ReadFile = (
path: string, path: string,
id: string | null | undefined, id: string | null | undefined,
@ -169,10 +182,6 @@ type ReadFile = (
type ReadFileMetadata = (path: string, id: string | null | undefined) => Promise<FileMetadata>; type ReadFileMetadata = (path: string, id: string | null | undefined) => Promise<FileMetadata>;
type ReadUnpublishedFile = (
key: string,
) => Promise<{ metaData: Metadata; fileData: string; isModification: boolean; slug: string }>;
const fetchFiles = async ( const fetchFiles = async (
files: ImplementationFile[], files: ImplementationFile[],
readFile: ReadFile, readFile: ReadFile,
@ -206,47 +215,6 @@ const fetchFiles = async (
) as Promise<ImplementationEntry[]>; ) as Promise<ImplementationEntry[]>;
}; };
const fetchUnpublishedFiles = async (
keys: string[],
readUnpublishedFile: ReadUnpublishedFile,
apiName: string,
) => {
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
const promises = [] as Promise<ImplementationEntry | { error: boolean }>[];
keys.forEach(key => {
promises.push(
new Promise(resolve =>
sem.take(() =>
readUnpublishedFile(key)
.then(data => {
if (data === null || data === undefined) {
resolve({ error: true });
sem.leave();
} else {
resolve({
slug: data.slug,
file: { path: data.metaData.objects.entry.path, id: null },
data: data.fileData,
metaData: data.metaData,
isModification: data.isModification,
});
sem.leave();
}
})
.catch((error = true) => {
sem.leave();
console.error(`failed to load file from ${apiName}: ${key}`);
resolve({ error });
}),
),
),
);
});
return Promise.all(promises).then(loadedEntries =>
loadedEntries.filter(loadedEntry => !(loadedEntry as { error: boolean }).error),
) as Promise<ImplementationEntry[]>;
};
export const entriesByFolder = async ( export const entriesByFolder = async (
listFiles: () => Promise<ImplementationFile[]>, listFiles: () => Promise<ImplementationFile[]>,
readFile: ReadFile, readFile: ReadFile,
@ -266,15 +234,10 @@ export const entriesByFiles = async (
return fetchFiles(files, readFile, readFileMetadata, apiName); return fetchFiles(files, readFile, readFileMetadata, apiName);
}; };
export const unpublishedEntries = async ( export const unpublishedEntries = async (listEntriesKeys: () => Promise<string[]>) => {
listEntriesKeys: () => Promise<string[]>,
readUnpublishedFile: ReadUnpublishedFile,
apiName: string,
) => {
try { try {
const keys = await listEntriesKeys(); const keys = await listEntriesKeys();
const entries = await fetchUnpublishedFiles(keys, readUnpublishedFile, apiName); return keys;
return entries;
} catch (error) { } catch (error) {
if (error.message === 'Not Found') { if (error.message === 'Not Found') {
return Promise.resolve([]); return Promise.resolve([]);
@ -392,7 +355,6 @@ type GetDiffFromLocalTreeMethods = {
oldPath: string; oldPath: string;
newPath: string; newPath: string;
status: string; status: string;
binary: boolean;
}[] }[]
>; >;
filterFile: (file: { path: string; name: string }) => boolean; filterFile: (file: { path: string; name: string }) => boolean;
@ -417,7 +379,7 @@ const getDiffFromLocalTree = async ({
}: GetDiffFromLocalTreeArgs) => { }: GetDiffFromLocalTreeArgs) => {
const diff = await getDifferences(branch.sha, localTree.head); const diff = await getDifferences(branch.sha, localTree.head);
const diffFiles = diff const diffFiles = diff
.filter(d => (d.oldPath?.startsWith(folder) || d.newPath?.startsWith(folder)) && !d.binary) .filter(d => d.oldPath?.startsWith(folder) || d.newPath?.startsWith(folder))
.reduce((acc, d) => { .reduce((acc, d) => {
if (d.status === 'renamed') { if (d.status === 'renamed') {
acc.push({ acc.push({

View File

@ -20,6 +20,7 @@ import { asyncLock, AsyncLock as AL } from './asyncLock';
import { import {
Implementation as I, Implementation as I,
ImplementationEntry as IE, ImplementationEntry as IE,
UnpublishedEntry as UE,
ImplementationMediaFile as IMF, ImplementationMediaFile as IMF,
ImplementationFile as IF, ImplementationFile as IF,
DisplayURLObject as DUO, DisplayURLObject as DUO,
@ -75,6 +76,7 @@ import {
export type AsyncLock = AL; export type AsyncLock = AL;
export type Implementation = I; export type Implementation = I;
export type ImplementationEntry = IE; export type ImplementationEntry = IE;
export type UnpublishedEntry = UE;
export type ImplementationMediaFile = IMF; export type ImplementationMediaFile = IMF;
export type ImplementationFile = IF; export type ImplementationFile = IF;
export type DisplayURL = DU; export type DisplayURL = DU;

View File

@ -81,6 +81,8 @@ const en = {
rangeCountExact: '%{fieldLabel} must have exactly %{count} item(s).', rangeCountExact: '%{fieldLabel} must have exactly %{count} item(s).',
minCount: '%{fieldLabel} must be at least %{minCount} item(s).', minCount: '%{fieldLabel} must be at least %{minCount} item(s).',
maxCount: '%{fieldLabel} must be %{maxCount} or less item(s).', maxCount: '%{fieldLabel} must be %{maxCount} or less item(s).',
invalidPath: `'%{path}' is not a valid path`,
pathExists: `Path '%{path}' already exists`,
}, },
}, },
editor: { editor: {

View File

@ -27,7 +27,8 @@
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"express": "^4.17.1", "express": "^4.17.1",
"morgan": "^1.9.1", "morgan": "^1.9.1",
"simple-git": "^2.0.0" "simple-git": "^2.0.0",
"what-the-diff": "^0.6.0"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.6", "@types/cors": "^2.8.6",

View File

@ -5,8 +5,8 @@ import Joi from '@hapi/joi';
const assetFailure = (result: Joi.ValidationResult, expectedMessage: string) => { const assetFailure = (result: Joi.ValidationResult, expectedMessage: string) => {
const { error } = result; const { error } = result;
expect(error).not.toBeNull(); expect(error).not.toBeNull();
expect(error.details).toHaveLength(1); expect(error!.details).toHaveLength(1);
const message = error.details.map(({ message }) => message)[0]; const message = error!.details.map(({ message }) => message)[0];
expect(message).toBe(expectedMessage); expect(message).toBe(expectedMessage);
}; };
@ -26,7 +26,7 @@ describe('defaultSchema', () => {
assetFailure( assetFailure(
schema.validate({ action: 'unknown', params: {} }), schema.validate({ action: 'unknown', params: {} }),
'"action" must be one of [info, entriesByFolder, entriesByFiles, getEntry, unpublishedEntries, unpublishedEntry, 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, getDeployPreview]',
); );
}); });
@ -157,28 +157,13 @@ describe('defaultSchema', () => {
describe('unpublishedEntry', () => { describe('unpublishedEntry', () => {
it('should fail on invalid params', () => { it('should fail on invalid params', () => {
const schema = defaultSchema(); const schema = defaultSchema();
assetFailure( assetFailure(
schema.validate({ action: 'unpublishedEntry', params: { ...defaultParams } }), schema.validate({ action: 'unpublishedEntry', params: {} }),
'"params.collection" is required', '"params.branch" is required',
);
assetFailure(
schema.validate({
action: 'unpublishedEntry',
params: { ...defaultParams, collection: 'collection' },
}),
'"params.slug" is required',
);
assetFailure(
schema.validate({
action: 'unpublishedEntry',
params: { ...defaultParams, collection: 'collection', slug: 1 },
}),
'"params.slug" must be a string',
); );
}); });
it('should pass on valid params', () => { it('should pass on valid collection and slug', () => {
const schema = defaultSchema(); const schema = defaultSchema();
const { error } = schema.validate({ const { error } = schema.validate({
action: 'unpublishedEntry', action: 'unpublishedEntry',
@ -187,6 +172,66 @@ describe('defaultSchema', () => {
expect(error).toBeUndefined(); expect(error).toBeUndefined();
}); });
it('should pass on valid id', () => {
const schema = defaultSchema();
const { error } = schema.validate({
action: 'unpublishedEntry',
params: { ...defaultParams, id: 'id' },
});
expect(error).toBeUndefined();
});
});
['unpublishedEntryDataFile', 'unpublishedEntryMediaFile'].forEach(action => {
describe(action, () => {
it('should fail on invalid params', () => {
const schema = defaultSchema();
assetFailure(
schema.validate({ action, params: { ...defaultParams } }),
'"params.collection" is required',
);
assetFailure(
schema.validate({
action,
params: { ...defaultParams, collection: 'collection' },
}),
'"params.slug" is required',
);
assetFailure(
schema.validate({
action,
params: { ...defaultParams, collection: 'collection', slug: 'slug' },
}),
'"params.id" is required',
);
assetFailure(
schema.validate({
action,
params: { ...defaultParams, collection: 'collection', slug: 'slug', id: 'id' },
}),
'"params.path" is required',
);
});
it('should pass on valid params', () => {
const schema = defaultSchema();
const { error } = schema.validate({
action,
params: {
...defaultParams,
collection: 'collection',
slug: 'slug',
id: 'id',
path: 'path',
},
});
expect(error).toBeUndefined();
});
});
}); });
describe('deleteUnpublishedEntry', () => { describe('deleteUnpublishedEntry', () => {

View File

@ -8,6 +8,8 @@ const allowedActions = [
'getEntry', 'getEntry',
'unpublishedEntries', 'unpublishedEntries',
'unpublishedEntry', 'unpublishedEntry',
'unpublishedEntryDataFile',
'unpublishedEntryMediaFile',
'deleteUnpublishedEntry', 'deleteUnpublishedEntry',
'persistEntry', 'persistEntry',
'updateUnpublishedEntryStatus', 'updateUnpublishedEntryStatus',
@ -75,10 +77,33 @@ export const defaultSchema = ({ path = requiredString } = {}) => {
}, },
{ {
is: 'unpublishedEntry', is: 'unpublishedEntry',
then: defaultParams
.keys({
id: Joi.string().optional(),
collection: Joi.string().optional(),
slug: Joi.string().optional(),
})
.required(),
},
{
is: 'unpublishedEntryDataFile',
then: defaultParams then: defaultParams
.keys({ .keys({
collection, collection,
slug, slug,
id: requiredString,
path: requiredString,
})
.required(),
},
{
is: 'unpublishedEntryMediaFile',
then: defaultParams
.keys({
collection,
slug,
id: requiredString,
path: requiredString,
}) })
.required(), .required(),
}, },
@ -95,7 +120,12 @@ export const defaultSchema = ({ path = requiredString } = {}) => {
is: 'persistEntry', is: 'persistEntry',
then: defaultParams then: defaultParams
.keys({ .keys({
entry: Joi.object({ slug: requiredString, path, raw: requiredString }).required(), entry: Joi.object({
slug: requiredString,
path,
raw: requiredString,
newPath: path.optional(),
}).required(),
assets: Joi.array() assets: Joi.array()
.items(asset) .items(asset)
.required(), .required(),

View File

@ -5,8 +5,8 @@ import { getSchema } from '.';
const assetFailure = (result: Joi.ValidationResult, expectedMessage: string) => { const assetFailure = (result: Joi.ValidationResult, expectedMessage: string) => {
const { error } = result; const { error } = result;
expect(error).not.toBeNull(); expect(error).not.toBeNull();
expect(error.details).toHaveLength(1); expect(error!.details).toHaveLength(1);
const message = error.details.map(({ message }) => message)[0]; const message = error!.details.map(({ message }) => message)[0];
expect(message).toBe(expectedMessage); expect(message).toBe(expectedMessage);
}; };

View File

@ -12,7 +12,7 @@ import {
PersistMediaParams, PersistMediaParams,
DeleteFileParams, DeleteFileParams,
} from '../types'; } from '../types';
import { listRepoFiles, deleteFile, writeFile } from '../utils/fs'; import { listRepoFiles, deleteFile, writeFile, move } from '../utils/fs';
import { entriesFromFiles, readMediaFile } from '../utils/entries'; import { entriesFromFiles, readMediaFile } from '../utils/entries';
type Options = { type Options = {
@ -67,6 +67,9 @@ export const localFsMiddleware = ({ repoPath }: Options) => {
writeFile(path.join(repoPath, a.path), Buffer.from(a.content, a.encoding)), 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));
}
res.json({ message: 'entry persisted' }); res.json({ message: 'entry persisted' });
break; break;
} }

View File

@ -9,8 +9,8 @@ jest.mock('simple-git/promise');
const assetFailure = (result: Joi.ValidationResult, expectedMessage: string) => { const assetFailure = (result: Joi.ValidationResult, expectedMessage: string) => {
const { error } = result; const { error } = result;
expect(error).not.toBeNull(); expect(error).not.toBeNull();
expect(error.details).toHaveLength(1); expect(error!.details).toHaveLength(1);
const message = error.details.map(({ message }) => message)[0]; const message = error!.details.map(({ message }) => message)[0];
expect(message).toBe(expectedMessage); expect(message).toBe(expectedMessage);
}; };

View File

@ -2,15 +2,15 @@ import express from 'express';
import path from 'path'; import path from 'path';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { import {
parseContentKey,
branchFromContentKey, branchFromContentKey,
generateContentKey, generateContentKey,
contentKeyFromBranch, contentKeyFromBranch,
CMS_BRANCH_PREFIX, CMS_BRANCH_PREFIX,
statusToLabel, statusToLabel,
labelToStatus, labelToStatus,
parseContentKey,
} from 'netlify-cms-lib-util/src/APIUtils'; } from 'netlify-cms-lib-util/src/APIUtils';
import { parse } from 'what-the-diff';
import { defaultSchema, joi } from '../joi'; import { defaultSchema, joi } from '../joi';
import { import {
EntriesByFolderParams, EntriesByFolderParams,
@ -27,16 +27,19 @@ import {
UpdateUnpublishedEntryStatusParams, UpdateUnpublishedEntryStatusParams,
Entry, Entry,
GetMediaFileParams, GetMediaFileParams,
DeleteEntryParams,
UnpublishedEntryDataFileParams,
UnpublishedEntryMediaFileParams,
} from '../types'; } from '../types';
// eslint-disable-next-line import/default // eslint-disable-next-line import/default
import simpleGit from 'simple-git/promise'; import simpleGit from 'simple-git/promise';
import { pathTraversal } from '../joi/customValidators'; import { pathTraversal } from '../joi/customValidators';
import { listRepoFiles, writeFile } from '../utils/fs'; import { listRepoFiles, writeFile, move } from '../utils/fs';
import { entriesFromFiles, readMediaFile } from '../utils/entries'; import { entriesFromFiles, readMediaFile } from '../utils/entries';
const commit = async (git: simpleGit.SimpleGit, commitMessage: string, files: string[]) => { const commit = async (git: simpleGit.SimpleGit, commitMessage: string) => {
await git.add(files); await git.add('.');
await git.commit(commitMessage, files, { await git.commit(commitMessage, undefined, {
'--no-verify': true, '--no-verify': true,
'--no-gpg-sign': true, '--no-gpg-sign': true,
}); });
@ -62,69 +65,10 @@ const runOnBranch = async <T>(git: simpleGit.SimpleGit, branch: string, func: ()
const branchDescription = (branch: string) => `branch.${branch}.description`; const branchDescription = (branch: string) => `branch.${branch}.description`;
const getEntryDataFromDiff = async (git: simpleGit.SimpleGit, branch: string, diff: string[]) => {
const contentKey = contentKeyFromBranch(branch);
const { collection, slug } = parseContentKey(contentKey);
const path = diff.find(d => d.includes(slug)) as string;
const mediaFiles = diff.filter(d => d !== path);
const label = await git.raw(['config', branchDescription(branch)]);
const status = label && labelToStatus(label.trim());
return {
slug,
metaData: { branch, collection, objects: { entry: { path, mediaFiles } }, status },
};
};
type Options = { type Options = {
repoPath: string; repoPath: string;
}; };
const entriesFromDiffs = async (
git: simpleGit.SimpleGit,
branch: string,
repoPath: string,
cmsBranches: string[],
diffs: simpleGit.DiffResult[],
) => {
const entries = [];
for (let i = 0; i < diffs.length; i++) {
const cmsBranch = cmsBranches[i];
const diff = diffs[i];
const data = await getEntryDataFromDiff(
git,
cmsBranch,
diff.files.map(f => f.file),
);
const entryPath = data.metaData.objects.entry.path;
const [entry] = await runOnBranch(git, cmsBranch, () =>
entriesFromFiles(repoPath, [{ path: entryPath }]),
);
const rawDiff = await git.diff([branch, cmsBranch, '--', entryPath]);
entries.push({
...data,
...entry,
isModification: !rawDiff.includes('new file'),
});
}
return entries;
};
const getEntryMediaFiles = async (
git: simpleGit.SimpleGit,
repoPath: string,
cmsBranch: string,
files: string[],
) => {
const mediaFiles = await runOnBranch(git, cmsBranch, async () => {
const serializedFiles = await Promise.all(files.map(file => readMediaFile(repoPath, file)));
return serializedFiles;
});
return mediaFiles;
};
const commitEntry = async ( const commitEntry = async (
git: simpleGit.SimpleGit, git: simpleGit.SimpleGit,
repoPath: string, repoPath: string,
@ -138,8 +82,12 @@ const commitEntry = async (
await Promise.all( await Promise.all(
assets.map(a => writeFile(path.join(repoPath, a.path), Buffer.from(a.content, a.encoding))), 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));
}
// commits files // commits files
await commit(git, commitMessage, [entry.path, ...assets.map(a => a.path)]); await commit(git, commitMessage);
}; };
const rebase = async (git: simpleGit.SimpleGit, branch: string) => { const rebase = async (git: simpleGit.SimpleGit, branch: string) => {
@ -175,6 +123,25 @@ const isBranchExists = async (git: simpleGit.SimpleGit, branch: string) => {
return branchExists; return branchExists;
}; };
const getDiffs = async (git: simpleGit.SimpleGit, source: string, dest: string) => {
const rawDiff = await git.diff([source, dest]);
const diffs = parse(rawDiff).map(d => {
const oldPath = d.oldPath?.replace(/b\//, '') || '';
const newPath = d.newPath?.replace(/b\//, '') || '';
const path = newPath || (oldPath as string);
return {
oldPath,
newPath,
status: d.status,
newFile: d.status === 'added',
path,
id: path,
binary: d.binary || /.svg$/.test(path),
};
});
return diffs;
};
export const validateRepo = async ({ repoPath }: Options) => { export const validateRepo = async ({ repoPath }: Options) => {
const git = simpleGit(repoPath).silent(false); const git = simpleGit(repoPath).silent(false);
const isRepo = await git.checkIsRepo(); const isRepo = await git.checkIsRepo();
@ -247,36 +214,53 @@ export const localGitMiddleware = ({ repoPath }: Options) => {
const cmsBranches = await git const cmsBranches = await git
.branchLocal() .branchLocal()
.then(result => result.all.filter(b => b.startsWith(`${CMS_BRANCH_PREFIX}/`))); .then(result => result.all.filter(b => b.startsWith(`${CMS_BRANCH_PREFIX}/`)));
res.json(cmsBranches.map(contentKeyFromBranch));
const diffs = await Promise.all(
cmsBranches.map(cmsBranch => git.diffSummary([branch, cmsBranch])),
);
const entries = await entriesFromDiffs(git, branch, repoPath, cmsBranches, diffs);
res.json(entries);
break; break;
} }
case 'unpublishedEntry': { case 'unpublishedEntry': {
const { collection, slug } = body.params as UnpublishedEntryParams; let { id, collection, slug } = body.params as UnpublishedEntryParams;
const contentKey = generateContentKey(collection, slug); if (id) {
({ collection, slug } = parseContentKey(id));
}
const contentKey = generateContentKey(collection as string, slug as string);
const cmsBranch = branchFromContentKey(contentKey); const cmsBranch = branchFromContentKey(contentKey);
const branchExists = await isBranchExists(git, cmsBranch); const branchExists = await isBranchExists(git, cmsBranch);
if (branchExists) { if (branchExists) {
const diff = await git.diffSummary([branch, cmsBranch]); const diffs = await getDiffs(git, branch, cmsBranch);
const [entry] = await entriesFromDiffs(git, branch, repoPath, [cmsBranch], [diff]); const label = await git.raw(['config', branchDescription(cmsBranch)]);
const mediaFiles = await getEntryMediaFiles( const status = label && labelToStatus(label.trim());
git, const unpublishedEntry = {
repoPath, collection,
cmsBranch, slug,
entry.metaData.objects.entry.mediaFiles, status,
); diffs,
res.json({ ...entry, mediaFiles }); };
res.json(unpublishedEntry);
} else { } else {
return res.status(404).json({ message: 'Not Found' }); return res.status(404).json({ message: 'Not Found' });
} }
break; break;
} }
case 'unpublishedEntryDataFile': {
const { path, collection, slug } = body.params as UnpublishedEntryDataFileParams;
const contentKey = generateContentKey(collection as string, slug as string);
const cmsBranch = branchFromContentKey(contentKey);
const [entry] = await runOnBranch(git, cmsBranch, () =>
entriesFromFiles(repoPath, [{ path }]),
);
res.json({ data: entry.data });
break;
}
case 'unpublishedEntryMediaFile': {
const { path, collection, slug } = body.params as UnpublishedEntryMediaFileParams;
const contentKey = generateContentKey(collection as string, slug as string);
const cmsBranch = branchFromContentKey(contentKey);
const file = await runOnBranch(git, cmsBranch, () => readMediaFile(repoPath, path));
res.json(file);
break;
}
case 'deleteUnpublishedEntry': { case 'deleteUnpublishedEntry': {
const { collection, slug } = body.params as UnpublishedEntryParams; const { collection, slug } = body.params as DeleteEntryParams;
const contentKey = generateContentKey(collection, slug); const contentKey = generateContentKey(collection, slug);
const cmsBranch = branchFromContentKey(contentKey); const cmsBranch = branchFromContentKey(contentKey);
const currentBranch = await getCurrentBranch(git); const currentBranch = await getCurrentBranch(git);
@ -290,7 +274,7 @@ export const localGitMiddleware = ({ repoPath }: Options) => {
case 'persistEntry': { case 'persistEntry': {
const { entry, assets, options } = body.params as PersistEntryParams; const { entry, assets, options } = body.params as PersistEntryParams;
if (!options.useWorkflow) { if (!options.useWorkflow) {
runOnBranch(git, branch, async () => { await runOnBranch(git, branch, async () => {
await commitEntry(git, repoPath, entry, assets, options.commitMessage); await commitEntry(git, repoPath, entry, assets, options.commitMessage);
}); });
} else { } else {
@ -306,28 +290,19 @@ export const localGitMiddleware = ({ repoPath }: Options) => {
await git.checkoutLocalBranch(cmsBranch); await git.checkoutLocalBranch(cmsBranch);
} }
await rebase(git, branch); await rebase(git, branch);
const diff = await git.diffSummary([branch, cmsBranch]); const diffs = await getDiffs(git, branch, cmsBranch);
const data = await getEntryDataFromDiff(
git,
branch,
diff.files.map(f => f.file),
);
// delete media files that have been removed from the entry // delete media files that have been removed from the entry
const toDelete = data.metaData.objects.entry.mediaFiles.filter( const toDelete = diffs.filter(
f => !assets.map(a => a.path).includes(f), d => d.binary && !assets.map(a => a.path).includes(d.path),
); );
await Promise.all(toDelete.map(f => fs.unlink(path.join(repoPath, f)))); 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, entry, assets, options.commitMessage);
// add status for new entries // add status for new entries
if (!data.metaData.status) { if (!branchExists) {
const description = statusToLabel(options.status); const description = statusToLabel(options.status);
await git.addConfig(branchDescription(cmsBranch), description); await git.addConfig(branchDescription(cmsBranch), description);
} }
// set path for new entries
if (!data.metaData.objects.entry.path) {
data.metaData.objects.entry.path = entry.path;
}
}); });
} }
res.json({ message: 'entry persisted' }); res.json({ message: 'entry persisted' });
@ -382,7 +357,7 @@ export const localGitMiddleware = ({ repoPath }: Options) => {
path.join(repoPath, asset.path), path.join(repoPath, asset.path),
Buffer.from(asset.content, asset.encoding), Buffer.from(asset.content, asset.encoding),
); );
await commit(git, commitMessage, [asset.path]); await commit(git, commitMessage);
return readMediaFile(repoPath, asset.path); return readMediaFile(repoPath, asset.path);
}); });
res.json(file); res.json(file);
@ -395,7 +370,7 @@ export const localGitMiddleware = ({ repoPath }: Options) => {
} = body.params as DeleteFileParams; } = body.params as DeleteFileParams;
await runOnBranch(git, branch, async () => { await runOnBranch(git, branch, async () => {
await fs.unlink(path.join(repoPath, filePath)); await fs.unlink(path.join(repoPath, filePath));
await commit(git, commitMessage, [filePath]); await commit(git, commitMessage);
}); });
res.json({ message: `deleted file ${filePath}` }); res.json({ message: `deleted file ${filePath}` });
break; break;

View File

@ -17,6 +17,26 @@ export type GetEntryParams = {
}; };
export type UnpublishedEntryParams = { export type UnpublishedEntryParams = {
id?: string;
collection?: string;
slug?: string;
};
export type UnpublishedEntryDataFileParams = {
collection: string;
slug: string;
id: string;
path: string;
};
export type UnpublishedEntryMediaFileParams = {
collection: string;
slug: string;
id: string;
path: string;
};
export type DeleteEntryParams = {
collection: string; collection: string;
slug: string; slug: string;
}; };
@ -32,7 +52,7 @@ export type PublishUnpublishedEntryParams = {
slug: string; slug: string;
}; };
export type Entry = { slug: string; path: string; raw: string }; export type Entry = { slug: string; path: string; raw: string; newPath?: string };
export type Asset = { path: string; content: string; encoding: 'base64' }; export type Asset = { path: string; content: string; encoding: 'base64' };

View File

@ -40,3 +40,19 @@ export const writeFile = async (filePath: string, content: Buffer | string) => {
export const deleteFile = async (repoPath: string, filePath: string) => { export const deleteFile = async (repoPath: string, filePath: string) => {
await fs.unlink(path.join(repoPath, filePath)); await fs.unlink(path.join(repoPath, filePath));
}; };
const moveFile = async (from: string, to: string) => {
await fs.mkdir(path.dirname(to), { recursive: true });
await fs.rename(from, to);
};
export const move = async (from: string, to: string) => {
// move file
await moveFile(from, to);
// move children
const sourceDir = path.dirname(from);
const destDir = path.dirname(to);
const allFiles = await listFiles(sourceDir, '', 100);
await Promise.all(allFiles.map(file => moveFile(file, file.replace(sourceDir, destDir))));
};

View File

@ -0,0 +1,5 @@
declare module 'what-the-diff' {
export const parse: (
rawDiff: string,
) => { oldPath?: string; newPath?: string; binary: boolean; status: string }[];
}

View File

@ -244,16 +244,25 @@ const buttons = {
`, `,
}; };
const caret = css`
color: ${colorsRaw.white};
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
`;
const components = { const components = {
card, card,
caretDown: css` caretDown: css`
color: ${colorsRaw.white}; ${caret};
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid currentColor; border-top: 6px solid currentColor;
border-radius: 2px; border-bottom: 0;
`,
caretRight: css`
${caret};
border-left: 6px solid currentColor;
border-right: 0;
`, `,
badge: css` badge: css`
${backgroundBadge}; ${backgroundBadge};

View File

@ -271,7 +271,7 @@ export default class ListControl extends React.Component {
getObjectValue = idx => this.props.value.get(idx) || Map(); getObjectValue = idx => this.props.value.get(idx) || Map();
handleChangeFor(index) { handleChangeFor(index) {
return (fieldName, newValue, newMetadata) => { return (f, newValue, newMetadata) => {
const { value, metadata, onChange, field } = this.props; const { value, metadata, onChange, field } = this.props;
const collectionName = field.get('name'); const collectionName = field.get('name');
const listFieldObjectWidget = field.getIn(['field', 'widget']) === 'object'; const listFieldObjectWidget = field.getIn(['field', 'widget']) === 'object';
@ -279,7 +279,7 @@ export default class ListControl extends React.Component {
this.getValueType() !== valueTypes.SINGLE || this.getValueType() !== valueTypes.SINGLE ||
(this.getValueType() === valueTypes.SINGLE && listFieldObjectWidget); (this.getValueType() === valueTypes.SINGLE && listFieldObjectWidget);
const newObjectValue = withNameKey const newObjectValue = withNameKey
? this.getObjectValue(index).set(fieldName, newValue) ? this.getObjectValue(index).set(f.get('name'), newValue)
: newValue; : newValue;
const parsedMetadata = { const parsedMetadata = {
[collectionName]: Object.assign(metadata ? metadata.toJS() : {}, newMetadata || {}), [collectionName]: Object.assign(metadata ? metadata.toJS() : {}, newMetadata || {}),

View File

@ -446,3 +446,32 @@ will open the editor for a new post with the `title` field populated with `first
with `second` and the markdown `body` field with `# content`. with `second` and the markdown `body` field with `# content`.
**Note:** URL Encoding might be required for certain values (e.g. in the previous example the value for `body` is URL encoded). **Note:** URL Encoding might be required for certain values (e.g. in the previous example the value for `body` is URL encoded).
## Nested Collections
Allows a folder collection to show a nested structure of entries and edit the locations of the entries.
Example configuration:
```yaml
collections:
- name: pages
label: Pages
label_singular: 'Page'
folder: content/pages
create: true
# adding a nested object will show the collection folder structure
nested:
depth: 100 # max depth to show in the collection tree
summary: '{{title}}' # optional summary for a tree node, defaults to the inferred title field
fields:
- label: Title
name: title
widget: string
- label: Body
name: body
widget: markdown
# adding a meta object with a path property allows editing the path of entries
# moving an existing entry will move the entire sub tree of the entry to the new location
meta: { path: { widget: string, label: 'Path', index_file: 'index' } }
```

2822
yarn.lock

File diff suppressed because it is too large Load Diff