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
},
{
"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",
"url": "/.netlify/git/github/git/trees",
"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
},
{
"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",
"url": "/repos/forkOwner/repo/git/trees",
"headers": {

View File

@ -1235,7 +1235,7 @@
"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",
"url": "/repos/owner/repo/git/trees",
"headers": {

View File

@ -1183,7 +1183,7 @@
"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",
"url": "/repos/owner/repo/git/trees",
"headers": {

View File

@ -1462,7 +1462,7 @@
"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",
"url": "/repos/forkOwner/repo/git/trees",
"headers": {

View File

@ -1286,7 +1286,7 @@
"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",
"url": "/repos/owner/repo/git/trees",
"headers": {

View File

@ -1234,7 +1234,7 @@
"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",
"url": "/repos/owner/repo/git/trees",
"headers": {

View File

@ -22,8 +22,10 @@ import {
populateEntry,
publishAndCreateNewEntryInEditor,
publishAndDuplicateEntryInEditor,
assertNotification,
assertFieldValidationError,
} from '../utils/steps';
import { workflowStatus, editorStatus, publishTypes } from '../utils/constants';
import { workflowStatus, editorStatus, publishTypes, notifications } from '../utils/constants';
const entry1 = {
title: 'first title',
@ -192,4 +194,123 @@ describe('Test Backend Editorial Workflow', () => {
updateWorkflowStatusInEditor(editorStatus.ready);
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 {
login,
newPost,

View File

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

View File

@ -34,8 +34,8 @@ const matchRoute = (route, fetchArgs) => {
const options = fetchArgs[1];
const method = options && options.method ? options.method : 'GET';
let body = options && options.body;
let routeBody = route.body;
const body = options && options.body;
const routeBody = route.body;
let bodyMatch = false;
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('api.media.atlassian.com') ||
Host.some(host => host.includes('netlify.com')) ||
Host.some(host => host.includes('netlify.app')) ||
Host.some(host => host.includes('s3.amazonaws.com'))
);
});

View File

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

View File

@ -45,8 +45,6 @@ collections: # A list of collections the CMS should be able to edit
tagname: ''
- { 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
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: 'Image', name: 'image', widget: 'image' }
- { 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"
}
}
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>
</head>
<body>

View File

@ -45,8 +45,6 @@ collections: # A list of collections the CMS should be able to edit
tagname: ''
- { 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
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: 'Image', name: 'image', widget: 'image' }
- { 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"
}
}
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>
</head>
<body>

View File

@ -28,6 +28,7 @@ import {
readFileMetadata,
throwOnConflictingBranches,
} from 'netlify-cms-lib-util';
import { dirname } from 'path';
import { oneLine } from 'common-tags';
import { parse } from 'what-the-diff';
@ -364,8 +365,8 @@ export default class API {
};
};
listFiles = async (path: string, depth = 1, pagelen = 20) => {
const node = await this.branchCommitSha(this.branch);
listFiles = async (path: string, depth = 1, pagelen: number, branch: string) => {
const node = await this.branchCommitSha(branch);
const result: BitBucketSrcResult = await this.requestJSON({
url: `${this.repoURL}/src/${node}/${path}`,
params: {
@ -398,11 +399,12 @@ export default class API {
})),
])(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(
path,
depth,
100,
branch,
);
const entries = [...initialEntries];
let currentCursor = initialCursor;
@ -418,7 +420,7 @@ export default class API {
};
async uploadFiles(
files: (Entry | AssetProxy | DeleteEntry)[],
files: { path: string; newPath?: string; delete?: boolean }[],
{
commitMessage,
branch,
@ -426,10 +428,14 @@ export default class API {
}: { commitMessage: string; branch: string; parentSha?: string },
) {
const formData = new FormData();
const toMove: { from: string; to: string; contentBlob: Blob }[] = [];
files.forEach(file => {
if ((file as DeleteEntry).delete) {
if (file.delete) {
// delete the file
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 {
// add/modify the file
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));
}
});
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) {
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 newPath = d.newPath?.replace(/b\//, '') || '';
const path = newPath || (oldPath as string);
return {
oldPath,
newPath,
binary: d.binary || /.svg$/.test(path),
status: d.status,
newFile: d.status === 'added',
path,
binary: d.binary || /.svg$/.test(path),
};
});
return diffs;
}
async editorialWorkflowGit(files: (Entry | AssetProxy)[], entry: Entry, options: PersistOptions) {
@ -573,8 +604,8 @@ export default class API {
// mark files for deletion
const diffs = await this.getDifferences(branch);
const toDelete: DeleteEntry[] = [];
for (const diff of diffs) {
if (!files.some(file => file.path === diff.newPath)) {
for (const diff of diffs.filter(d => d.binary && d.status !== 'deleted')) {
if (!files.some(file => file.path === diff.path)) {
toDelete.push({ path: diff.path, delete: true });
}
}
@ -637,47 +668,6 @@ export default class API {
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() {
console.log(
'%c Checking for Unpublished entries',
@ -690,6 +680,26 @@ export default class API {
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) {
const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);

View File

@ -23,7 +23,6 @@ import {
Config,
ImplementationFile,
unpublishedEntries,
UnpublishedEntryMediaFile,
runWithLock,
AsyncLock,
asyncLock,
@ -38,6 +37,7 @@ import {
localForage,
allEntriesByFolder,
AccessTokenError,
branchFromContentKey,
} from 'netlify-cms-lib-util';
import { NetlifyAuthenticator } from 'netlify-cms-lib-auth';
import AuthenticationPage from './AuthenticationPage';
@ -299,7 +299,7 @@ export default class BitbucketBackend implements Implementation {
let cursor: Cursor;
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 });
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) {
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));
return filtered;
}
@ -371,7 +371,7 @@ export default class BitbucketBackend implements Implementation {
}
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 } })),
);
}
@ -509,31 +509,26 @@ export default class BitbucketBackend implements Implementation {
});
}
loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) {
const readFile = (
async loadMediaFile(path: string, id: string, { branch }: { branch: string }) {
const readFile = async (
path: string,
id: string | null | undefined,
{ parseText }: { parseText: boolean },
) => this.api!.readFile(path, id, { branch, parseText });
return getMediaAsBlob(file.path, null, readFile).then(blob => {
const name = basename(file.path);
) => {
const content = await this.api!.readFile(path, id, { branch, parseText });
return content;
};
const blob = await getMediaAsBlob(path, id, readFile);
const name = basename(path);
const fileObj = blobToFileObj(name, blob);
return {
id: file.path,
id: path,
displayURL: URL.createObjectURL(fileObj),
path: file.path,
path,
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() {
@ -542,37 +537,47 @@ export default class BitbucketBackend implements Implementation {
branches.map(branch => contentKeyFromBranch(branch)),
);
const readUnpublishedBranchFile = (contentKey: string) =>
this.api!.readUnpublishedBranchFile(contentKey);
return unpublishedEntries(listEntriesKeys, readUnpublishedBranchFile, API_NAME);
const ids = await unpublishedEntries(listEntriesKeys);
return ids;
}
async unpublishedEntry(
collection: string,
slug: string,
{
loadEntryMediaFiles = (branch: string, files: UnpublishedEntryMediaFile[]) =>
this.loadEntryMediaFiles(branch, files),
} = {},
) {
const contentKey = generateContentKey(collection, slug);
const data = await this.api!.readUnpublishedBranchFile(contentKey);
const mediaFiles = await loadEntryMediaFiles(
data.metaData.branch,
// TODO: fix this
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
data.metaData.objects.entry.mediaFiles,
);
return {
async unpublishedEntry({
id,
collection,
slug,
file: { path: data.metaData.objects.entry.path, id: null },
data: data.fileData as string,
metaData: data.metaData,
mediaFiles,
isModification: data.isModification,
};
}: {
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 branch = branchFromContentKey(contentKey);
return branch;
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const data = (await this.api!.readFile(path, id, { branch })) as string;
return data;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const mediaFile = await this.loadMediaFile(path, id, { branch });
return mediaFile;
}
async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {

View File

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

View File

@ -29,6 +29,7 @@ import {
ApiRequest,
throwOnConflictingBranches,
} from 'netlify-cms-lib-util';
import { dirname } from 'path';
import { Octokit } from '@octokit/rest';
type GitHubUser = Octokit.UsersGetAuthenticatedResponse;
@ -154,6 +155,24 @@ const getTreeFiles = (files: GitHubCompareFiles) => {
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;
export default class API {
@ -497,7 +516,9 @@ export default class API {
// 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 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
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
@ -552,65 +573,22 @@ export default class API {
}
}
matchingEntriesFromDiffs(diffs: Octokit.ReposCompareCommitsResponseFilesItem[]) {
// 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) {
async retrieveUnpublishedEntryData(contentKey: string) {
const { collection, slug } = this.parseContentKey(contentKey);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
const { files: diffs } = await this.getDifferences(this.branch, pullRequest.head.sha);
const matchingEntries = this.matchingEntriesFromDiffs(diffs);
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 { files } = await this.getDifferences(this.branch, pullRequest.head.sha);
const diffs = files.map(diffFromFile);
const label = pullRequest.labels.find(l => isCMSLabel(l.name)) as { name: string };
const status = labelToStatus(label.name);
const timeStamp = pullRequest.updated_at;
return { branch, collection, slug, path, status, newFile, mediaFiles, timeStamp, pullRequest };
const updatedAt = pullRequest.updated_at;
return {
collection,
slug,
status,
diffs: diffs.map(d => ({ path: d.path, newFile: d.newFile, id: d.sha })),
updatedAt,
};
}
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) => {
try {
const pullRequest = await this.getBranchPullRequest(branch);
@ -1044,16 +983,17 @@ export default class API {
}
} else {
// 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,
await this.getHeadReference(branch),
);
const diffs = diffFiles.map(diffFromFile);
// mark media files to remove
const mediaFilesToRemove: { path: string; sha: string | null }[] = [];
for (const diff of diffs) {
if (!mediaFilesList.some(file => file.path === diff.filename)) {
mediaFilesToRemove.push({ path: diff.filename, sha: null });
for (const diff of diffs.filter(d => d.binary)) {
if (!mediaFilesList.some(file => file.path === diff.path)) {
mediaFilesToRemove.push({ path: diff.path, sha: null });
}
}
@ -1414,30 +1354,67 @@ export default class API {
return Promise.resolve(Base64.encode(str));
}
uploadBlob(item: { raw?: string; sha?: string; toBase64?: () => Promise<string> }) {
const content = result(item, 'toBase64', partial(this.toBase64, item.raw as string));
return content.then(contentBase64 =>
this.request(`${this.repoURL}/git/blobs`, {
async uploadBlob(item: { raw?: string; sha?: string; toBase64?: () => Promise<string> }) {
const contentBase64 = await result(
item,
'toBase64',
partial(this.toBase64, item.raw as string),
);
const response = await this.request(`${this.repoURL}/git/blobs`, {
method: 'POST',
body: JSON.stringify({
content: contentBase64,
encoding: 'base64',
}),
}).then(response => {
});
item.sha = response.sha;
return item;
}),
);
}
async updateTree(baseSha: string, files: { path: string; sha: string | null }[]) {
const tree: TreeEntry[] = files.map(file => ({
async updateTree(
baseSha: string,
files: { path: string; sha: string | null; newPath?: string }[],
branch = this.branch,
) {
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);
return { ...newTree, parentSha: baseSha };

View File

@ -403,6 +403,9 @@ export default class GraphQLAPI extends API {
...this.getBranchQuery(branch, this.repoOwner, this.repoName),
fetchPolicy: CACHE_FIRST,
});
if (!data.repository.branch) {
throw new APIError('Branch not found', 404, API_NAME);
}
return data.repository.branch;
}
@ -539,12 +542,9 @@ export default class GraphQLAPI extends API {
try {
const contentKey = this.generateContentKey(collectionName, slug);
const branchName = branchFromContentKey(contentKey);
const metadata = await this.retrieveMetadata(contentKey);
if (metadata.pullRequest.number !== MOCK_PULL_REQUEST) {
const { branch, pullRequest } = await this.getPullRequestAndBranch(
branchName,
metadata.pullRequest.number,
);
const pr = await this.getBranchPullRequest(branchName);
if (pr.number !== MOCK_PULL_REQUEST) {
const { branch, pullRequest } = await this.getPullRequestAndBranch(branchName, pr.number);
const { data } = await this.mutate({
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', () => {
const generateContentKey = jest.fn();
const readUnpublishedBranchFile = jest.fn();
const retrieveUnpublishedEntryData = jest.fn();
const mockAPI = {
generateContentKey,
readUnpublishedBranchFile,
retrieveUnpublishedEntryData,
};
it('should return unpublished entry', async () => {
it('should return unpublished entry data', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
gitHubImplementation.loadEntryMediaFiles = jest
@ -183,37 +151,25 @@ describe('github backend implementation', () => {
generateContentKey.mockReturnValue('contentKey');
const data = {
fileData: 'fileData',
isModification: true,
metaData: {
branch: 'branch',
objects: {
entry: { path: 'entry-path', mediaFiles: [{ path: 'image.png', id: 'sha' }] },
},
},
collection: 'collection',
slug: 'slug',
status: 'draft',
diffs: [],
updatedAt: 'updatedAt',
};
readUnpublishedBranchFile.mockResolvedValue(data);
retrieveUnpublishedEntryData.mockResolvedValue(data);
const collection = 'posts';
await expect(gitHubImplementation.unpublishedEntry(collection, 'slug')).resolves.toEqual({
slug: 'slug',
file: { path: 'entry-path', id: null },
data: 'fileData',
metaData: data.metaData,
mediaFiles: [{ path: 'image.png', id: 'sha' }],
isModification: true,
});
const slug = 'slug';
await expect(gitHubImplementation.unpublishedEntry({ collection, slug })).resolves.toEqual(
data,
);
expect(generateContentKey).toHaveBeenCalledTimes(1);
expect(generateContentKey).toHaveBeenCalledWith('posts', 'slug');
expect(readUnpublishedBranchFile).toHaveBeenCalledTimes(1);
expect(readUnpublishedBranchFile).toHaveBeenCalledWith('contentKey');
expect(gitHubImplementation.loadEntryMediaFiles).toHaveBeenCalledTimes(1);
expect(gitHubImplementation.loadEntryMediaFiles).toHaveBeenCalledWith('branch', [
{ path: 'image.png', id: 'sha' },
]);
expect(retrieveUnpublishedEntryData).toHaveBeenCalledTimes(1);
expect(retrieveUnpublishedEntryData).toHaveBeenCalledWith('contentKey');
});
});

View File

@ -29,6 +29,7 @@ import {
blobToFileObj,
contentKeyFromBranch,
unsentRequest,
branchFromContentKey,
} from 'netlify-cms-lib-util';
import AuthenticationPage from './AuthenticationPage';
import { Octokit } from '@octokit/rest';
@ -546,14 +547,14 @@ export default class GitHub implements Implementation {
};
}
loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) {
async loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) {
const readFile = (
path: string,
id: string | null | undefined,
{ parseText }: { parseText: boolean },
) => 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 fileObj = blobToFileObj(name, blob);
return {
@ -564,50 +565,55 @@ export default class GitHub implements Implementation {
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;
}
unpublishedEntries() {
async unpublishedEntries() {
const listEntriesKeys = () =>
this.api!.listUnpublishedBranches().then(branches =>
branches.map(branch => contentKeyFromBranch(branch)),
);
const readUnpublishedBranchFile = (contentKey: string) =>
this.api!.readUnpublishedBranchFile(contentKey);
return unpublishedEntries(listEntriesKeys, readUnpublishedBranchFile, 'GitHub');
const ids = await unpublishedEntries(listEntriesKeys);
return ids;
}
async unpublishedEntry(
collection: string,
slug: string,
{
loadEntryMediaFiles = (branch: string, files: UnpublishedEntryMediaFile[]) =>
this.loadEntryMediaFiles(branch, files),
} = {},
) {
const contentKey = this.api!.generateContentKey(collection, slug);
const data = await this.api!.readUnpublishedBranchFile(contentKey);
const files = data.metaData.objects.entry.mediaFiles || [];
const mediaFiles = await loadEntryMediaFiles(
data.metaData.branch,
files.map(({ id, path }) => ({ id, path })),
);
return {
async unpublishedEntry({
id,
collection,
slug,
file: { path: data.metaData.objects.entry.path, id: null },
data: data.fileData as string,
metaData: data.metaData,
mediaFiles,
isModification: data.isModification,
};
}: {
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 branch = branchFromContentKey(contentKey);
return branch;
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const data = (await this.api!.readFile(path, id, { branch })) as string;
return data;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const mediaFile = await this.loadMediaFile(branch, { path, id });
return mediaFile;
}
async getDeployPreview(collection: string, slug: string) {

View File

@ -30,6 +30,7 @@ import {
import { Base64 } from 'js-base64';
import { Map } from 'immutable';
import { flow, partial, result, trimStart } from 'lodash';
import { dirname } from 'path';
export const API_NAME = 'GitLab';
@ -57,6 +58,7 @@ enum CommitAction {
type CommitItem = {
base64Content?: string;
path: string;
oldPath?: string;
action: CommitAction;
};
@ -68,6 +70,7 @@ interface CommitsParams {
actions?: {
action: string;
file_path: string;
previous_path?: string;
content?: 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 = [];
// eslint-disable-next-line prefer-const
let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({
url: `${this.repoURL}/repository/tree`,
// Get the maximum number of entries per page
// 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);
while (cursor && cursor.actions!.has('next')) {
@ -423,7 +426,11 @@ export default class API {
action: item.action,
// eslint-disable-next-line @typescript-eslint/camelcase
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 = {
@ -459,21 +466,49 @@ export default class API {
}
}
async getCommitItems(files: (Entry | AssetProxy)[], branch: string) {
const items = await Promise.all(
async getCommitItems(files: { path: string; newPath?: string }[], branch: string) {
const items: CommitItem[] = await Promise.all(
files.map(async file => {
const [base64Content, fileExists] = await Promise.all([
result(file, 'toBase64', partial(this.toBase64, (file as Entry).raw)),
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 {
action: fileExists ? CommitAction.UPDATE : CommitAction.CREATE,
action,
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) {
@ -604,54 +639,33 @@ export default class API {
oldPath: d.old_path,
newPath: d.new_path,
newFile: d.new_file,
path: d.new_path || d.old_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 branch = branchFromContentKey(contentKey);
const mergeRequest = await this.getBranchMergeRequest(branch);
const diff = await this.getDifferences(mergeRequest.sha);
const { oldPath: path, newFile: newFile } = diff.find(d => !d.binary) as {
oldPath: string;
newFile: boolean;
};
const mediaFiles = await Promise.all(
diff
.filter(d => d.oldPath !== path)
.map(async d => {
const path = d.newPath;
const diffs = await this.getDifferences(mergeRequest.sha);
const diffsWithIds = await Promise.all(
diffs.map(async d => {
const { path, newFile } = d;
const id = await this.getFileId(path, branch);
return { path, id };
return { id, path, newFile };
}),
);
const label = mergeRequest.labels.find(isCMSLabel) as string;
const status = labelToStatus(label);
const timeStamp = mergeRequest.updated_at;
return { branch, collection, slug, path, status, newFile, mediaFiles, timeStamp };
}
async readUnpublishedBranchFile(contentKey: string) {
const {
branch,
const updatedAt = mergeRequest.updated_at;
return {
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,
diffs: diffsWithIds,
updatedAt,
};
}
@ -726,10 +740,9 @@ export default class API {
this.getCommitItems(files, branch),
this.getDifferences(branch),
]);
// mark files for deletion
for (const diff of diffs) {
if (!items.some(item => item.path === diff.newPath)) {
for (const diff of diffs.filter(d => d.binary)) {
if (!items.some(item => item.path === diff.path)) {
items.push({ action: CommitAction.DELETE, path: diff.newPath });
}
}

View File

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

View File

@ -9,6 +9,7 @@ import {
EditorialWorkflowError,
APIError,
unsentRequest,
UnpublishedEntry,
} from 'netlify-cms-lib-util';
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 {
const entry = await this.request({
const entry: UnpublishedEntry = await this.request({
action: 'unpublishedEntry',
params: { branch: this.branch, collection, slug },
params: { branch: this.branch, id, collection, slug },
});
const mediaFiles = entry.mediaFiles.map(deserializeMediaFile);
return { ...entry, mediaFiles };
return entry;
} catch (e) {
if (e.status === 404) {
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) {
return this.request({
action: 'deleteUnpublishedEntry',

View File

@ -1,4 +1,4 @@
import TestBackend, { getFolderEntries } from '../implementation';
import TestBackend, { getFolderFiles } from '../implementation';
describe('test backend implementation', () => {
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({
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({
file: { path: 'posts/dir1/dir2/some-post.md', id: null },
@ -49,7 +49,7 @@ describe('test backend implementation', () => {
it('should persist entry', async () => {
window.repoFiles = {};
const backend = new TestBackend();
const backend = new TestBackend({});
const entry = { path: 'posts/some-post.md', raw: 'content', slug: 'some-post.md' };
await backend.persistEntry(entry, [], { newEntry: true });
@ -58,6 +58,7 @@ describe('test backend implementation', () => {
posts: {
'some-post.md': {
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' };
await backend.persistEntry(entry, [], { newEntry: true });
@ -91,6 +92,7 @@ describe('test backend implementation', () => {
posts: {
'new-post.md': {
content: 'content',
path: 'posts/new-post.md',
},
'other-post.md': {
content: 'content',
@ -102,7 +104,7 @@ describe('test backend implementation', () => {
it('should persist nested entry', async () => {
window.repoFiles = {};
const backend = new TestBackend();
const backend = new TestBackend({});
const slug = 'dir1/dir2/some-post.md';
const path = `posts/${slug}`;
@ -115,6 +117,7 @@ describe('test backend implementation', () => {
dir2: {
'some-post.md': {
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 path = `posts/${slug}`;
@ -148,7 +151,7 @@ describe('test backend implementation', () => {
dir1: {
dir2: {
'some-post.md': {
mediaFiles: ['file1'],
path: 'posts/dir1/dir2/some-post.md',
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');
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');
expect(window.repoFiles).toEqual({
@ -202,7 +205,7 @@ describe('test backend implementation', () => {
});
});
describe('getFolderEntries', () => {
describe('getFolderFiles', () => {
it('should get files by depth', () => {
const tree = {
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 },
data: 'root page content',
path: 'pages/root-page.md',
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 },
data: 'nested page 1 content',
path: 'pages/dir1/nested-page-1.md',
content: 'nested page 1 content',
},
{
file: { path: 'pages/root-page.md', id: null },
data: 'root page content',
path: 'pages/root-page.md',
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 },
data: 'nested page 2 content',
path: 'pages/dir1/dir2/nested-page-2.md',
content: 'nested page 2 content',
},
{
file: { path: 'pages/dir1/nested-page-1.md', id: null },
data: 'nested page 1 content',
path: 'pages/dir1/nested-page-1.md',
content: 'nested page 1 content',
},
{
file: { path: 'pages/root-page.md', id: null },
data: 'root page content',
path: 'pages/root-page.md',
content: 'root page content',
},
]);
});

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import {
retrieveLocalBackup,
persistLocalBackup,
getMediaAssets,
validateMetaField,
} from '../entries';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
@ -13,6 +14,8 @@ import AssetProxy from '../../valueObjects/AssetProxy';
jest.mock('coreSrc/backend');
jest.mock('netlify-cms-lib-util');
jest.mock('../mediaLibrary');
jest.mock('../../reducers/entries');
jest.mock('../../reducers/entryDraft');
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
@ -45,14 +48,15 @@ describe('entries', () => {
author: '',
collection: undefined,
data: {},
meta: {},
isModification: null,
label: null,
mediaFiles: [],
metaData: null,
partial: false,
path: '',
raw: '',
slug: '',
status: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
@ -76,14 +80,15 @@ describe('entries', () => {
author: '',
collection: undefined,
data: { title: 'title', boolean: true },
meta: {},
isModification: null,
label: null,
mediaFiles: [],
metaData: null,
partial: false,
path: '',
raw: '',
slug: '',
status: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
@ -109,14 +114,15 @@ describe('entries', () => {
author: '',
collection: undefined,
data: { title: '&lt;script&gt;alert(&#039;hello&#039;)&lt;/script&gt;' },
meta: {},
isModification: null,
label: null,
mediaFiles: [],
metaData: null,
partial: false,
path: '',
raw: '',
slug: '',
status: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
@ -383,4 +389,170 @@ describe('entries', () => {
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 { Map, fromJS } from 'immutable';
import { trimStart, get, isPlainObject } from 'lodash';
import { trimStart, trim, get, isPlainObject } from 'lodash';
import { authenticateUser } from 'Actions/auth';
import * as publishModes from 'Constants/publishModes';
import { validateConfig } from 'Constants/configSchema';
@ -82,11 +82,28 @@ export function applyDefaults(config) {
'fields',
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');
if (files) {
collection = collection.delete('nested');
collection = collection.delete('meta');
collection = collection.set(
'files',
files.map(file => {

View File

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

View File

@ -26,7 +26,10 @@ import { ThunkDispatch } from 'redux-thunk';
import { AnyAction } from 'redux';
import { waitForMediaLibraryToLoad, loadMedia } from './mediaLibrary';
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;
@ -336,7 +339,7 @@ export function discardDraft() {
}
export function changeDraftField(
field: string,
field: EntryField,
value: string,
metadata: Record<string, unknown>,
entries: EntryMap[],
@ -520,7 +523,10 @@ export function loadEntries(collection: Collection, page = 0) {
cursor: Cursor;
pagination: number;
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,
// 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 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 backend = currentBackend(state.config);
@ -659,6 +666,8 @@ export function createEmptyDraft(collection: Collection, search: string) {
let newEntry = createEntry(collection.get('name'), '', '', {
data: dataFields,
mediaFiles: [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
meta: metaFields as any,
});
newEntry = await backend.processEntry(state, collection, newEntry);
dispatch(emptyDraftCreated(newEntry));
@ -791,7 +800,7 @@ export function persistEntry(collection: Collection) {
assetProxies,
usedSlugs,
})
.then((slug: string) => {
.then((newSlug: string) => {
dispatch(
notifSend({
message: {
@ -805,8 +814,14 @@ export function persistEntry(collection: Collection) {
if (assetProxies.length > 0) {
dispatch(loadMedia());
}
dispatch(entryPersisted(collection, serializedEntry, slug));
if (serializedEntry.get('newRecord')) return dispatch(loadEntry(collection, slug));
dispatch(entryPersisted(collection, serializedEntry, newSlug));
if (collection.has('nested')) {
dispatch(loadEntries(collection));
}
if (entry.get('slug') !== newSlug) {
dispatch(loadEntry(collection, newSlug));
navigateToEntry(collection.get('name'), newSlug);
}
})
.catch((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 * as fuzzy from 'fuzzy';
import { resolveFormat } from './formats/formats';
@ -15,6 +15,7 @@ import {
selectInferedField,
selectMediaFolders,
selectFieldsComments,
selectHasMetaPath,
} from './reducers/collections';
import { createEntry, EntryValue } from './valueObjects/Entry';
import { sanitizeChar } from './lib/urlHelper';
@ -34,6 +35,7 @@ import {
Config as ImplementationConfig,
blobToFileObj,
} from 'netlify-cms-lib-util';
import { basename, join, extname, dirname } from 'path';
import { status } from './constants/publishModes';
import { stringTemplate } from 'netlify-cms-lib-widgets';
import {
@ -49,6 +51,8 @@ import {
} from './types/redux';
import AssetProxy from './valueObjects/AssetProxy';
import { FOLDER, FILES } from './constants/collectionTypes';
import { selectCustomPath } from './reducers/entryDraft';
import { UnpublishedEntry } from 'netlify-cms-lib-util/src/implementation';
const { extractTemplateVars, dateParsers } = stringTemplate;
@ -103,6 +107,13 @@ const sortByScore = (a: fuzzy.FilterResult<EntryValue>, b: fuzzy.FilterResult<En
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 {
retrieve: () => User;
store: (user: User) => void;
@ -153,6 +164,14 @@ type Implementation = BackendImplementation & {
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 {
implementation: Implementation;
backendName: string;
@ -261,7 +280,9 @@ export class Backend {
async entryExist(collection: Collection, path: string, slug: string, useWorkflow: boolean) {
const unpublishedEntry =
useWorkflow &&
(await this.implementation.unpublishedEntry(collection.get('name'), slug).catch(error => {
(await this.implementation
.unpublishedEntry({ collection: collection.get('name'), slug })
.catch(error => {
if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) {
return Promise.resolve(false);
}
@ -285,9 +306,15 @@ export class Backend {
entryData: Map<string, unknown>,
config: Config,
usedSlugs: List<string>,
customPath: string | undefined,
) {
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 uniqueSlug = slug;
@ -334,12 +361,17 @@ export class Backend {
let listMethod: () => Promise<ImplementationEntry[]>;
const collectionType = collection.get('type');
if (collectionType === FOLDER) {
listMethod = () =>
this.implementation.entriesByFolder(
listMethod = () => {
const depth =
collection.get('nested')?.get('depth') ||
getPathDepth(collection.get('path', '') as string);
return this.implementation.entriesByFolder(
collection.get('folder') as string,
extension,
getPathDepth(collection.get('path', '') as string),
depth,
);
};
} else if (collectionType === FILES) {
const files = collection
.get('files')!
@ -379,12 +411,12 @@ export class Backend {
async listAllEntries(collection: Collection) {
if (collection.get('folder') && this.implementation.allEntriesByFolder) {
const extension = selectFolderEntryExtension(collection);
const depth =
collection.get('nested')?.get('depth') ||
getPathDepth(collection.get('path', '') as string);
return this.implementation
.allEntriesByFolder(
collection.get('folder') as string,
extension,
getPathDepth(collection.get('path', '') as string),
)
.allEntriesByFolder(collection.get('folder') as string, extension, depth)
.then(entries => this.processEntries(entries, collection));
}
@ -491,7 +523,12 @@ export class Backend {
const label = selectFileEntryLabel(collection, slug);
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 };
@ -548,6 +585,7 @@ export class Backend {
raw: loadedEntry.data,
label,
mediaFiles: [],
meta: { path: prepareMetaPath(loadedEntry.file.path, collection) },
});
entry = this.entryWithFormat(collection)(entry);
@ -586,35 +624,93 @@ export class Backend {
};
}
unpublishedEntries(collections: Collections) {
return this.implementation.unpublishedEntries!()
.then(entries =>
entries.map(loadedEntry => {
const collectionName = loadedEntry.metaData!.collection;
const collection = collections.find(c => c.get('name') === collectionName);
const entry = createEntry(collectionName, loadedEntry.slug, loadedEntry.file.path, {
raw: loadedEntry.data,
isModification: loadedEntry.isModification,
label: collection && selectFileEntryLabel(collection, loadedEntry.slug!),
async processUnpublishedEntry(
collection: Collection,
entryData: UnpublishedEntry,
withMediaFiles: boolean,
) {
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) },
});
entry.metaData = loadedEntry.metaData;
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);
if (!collection) {
console.warn(`Missing collection '${collectionName}' for unpublished entry '${id}'`);
return null;
}
const entry = await this.processUnpublishedEntry(collection, entryData, false);
return entry;
}),
)
.then(entries => ({
pagination: 0,
entries: entries.reduce((acc, entry) => {
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[]),
}));
).filter(Boolean) as EntryValue[];
return { pagination: 0, entries };
}
async processEntry(state: State, collection: Collection, entry: EntryValue) {
@ -633,19 +729,12 @@ export class Backend {
}
async unpublishedEntry(state: State, collection: Collection, slug: string) {
const loadedEntry = await this.implementation!.unpublishedEntry!(
collection.get('name') as string,
const entryData = await this.implementation!.unpublishedEntry!({
collection: collection.get('name') as string,
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);
return entry;
}
@ -738,12 +827,17 @@ export class Backend {
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
const useWorkflow = selectUseWorkflow(config);
let entryObj: {
path: string;
slug: string;
raw: string;
newPath?: string;
};
const customPath = selectCustomPath(collection, entryDraft);
if (newEntry) {
if (!selectAllowNewEntries(collection)) {
throw new Error('Not allowed to create new entries in this collection');
@ -753,9 +847,9 @@ export class Backend {
entryDraft.getIn(['entry', 'data']),
config,
usedSlugs,
customPath,
);
const path = selectEntryPath(collection, slug) as string;
const path = customPath || (selectEntryPath(collection, slug) as string);
entryObj = {
path,
slug,
@ -775,12 +869,13 @@ export class Backend {
asset.path = newPath;
});
} else {
const path = entryDraft.getIn(['entry', 'path']);
const slug = entryDraft.getIn(['entry', 'slug']);
entryObj = {
path,
slug,
path: entryDraft.getIn(['entry', 'path']),
// for workflow entries we refresh the slug on publish
slug: customPath && !useWorkflow ? slugFromCustomPath(collection, customPath) : slug,
raw: this.entryToRaw(collection, entryDraft.get('entry')),
newPath: customPath,
};
}
@ -798,8 +893,6 @@ export class Backend {
user.useOpenAuthoring,
);
const useWorkflow = selectUseWorkflow(config);
const collectionName = collection.get('name');
const updatedOptions = { unpublished, status };

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import { selectEntries, selectEntriesLoaded, selectIsFetching } from '../../../r
import { selectCollectionEntriesCursor } from 'Reducers/cursors';
import Entries from './Entries';
class EntriesCollection extends React.Component {
export class EntriesCollection extends React.Component {
static propTypes = {
collection: ImmutablePropTypes.map.isRequired,
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) {
const { collection, viewStyle } = ownProps;
const { collection, viewStyle, filterTerm } = ownProps;
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 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 { searchCollections } from 'Actions/collections';
import CollectionSearch from './CollectionSearch';
import NestedCollection from './NestedCollection';
const styles = {
sidebarNavLinkActive: css`
@ -64,23 +65,35 @@ const SidebarNavLink = styled(NavLink)`
`};
`;
class Sidebar extends React.Component {
export class Sidebar extends React.Component {
static propTypes = {
collections: ImmutablePropTypes.orderedMap.isRequired,
collection: ImmutablePropTypes.map,
searchTerm: PropTypes.string,
filterTerm: PropTypes.string,
t: PropTypes.func.isRequired,
};
static defaultProps = {
searchTerm: '',
};
renderLink = collection => {
renderLink = (collection, filterTerm) => {
const collectionName = collection.get('name');
if (collection.has('nested')) {
return (
<li key={collectionName}>
<SidebarNavLink to={`/collections/${collectionName}`} activeClassName="sidebar-active">
<NestedCollection
collection={collection}
filterTerm={filterTerm}
data-testid={collectionName}
/>
</li>
);
}
return (
<li key={collectionName}>
<SidebarNavLink
to={`/collections/${collectionName}`}
activeClassName="sidebar-active"
data-testid={collectionName}
>
<Icon type="write" />
{collection.get('label')}
</SidebarNavLink>
@ -89,7 +102,8 @@ class Sidebar extends React.Component {
};
render() {
const { collections, collection, searchTerm, t } = this.props;
const { collections, collection, searchTerm, t, filterTerm } = this.props;
return (
<SidebarContainer>
<SidebarHeading>{t('collection.sidebar.collections')}</SidebarHeading>
@ -103,7 +117,7 @@ class Sidebar extends React.Component {
{collections
.toList()
.filter(collection => collection.get('hide') !== true)
.map(this.renderLink)}
.map(collection => this.renderLink(collection, filterTerm))}
</SidebarNavList>
</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 EditorInterface from './EditorInterface';
import withWorkflow from './withWorkflow';
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}`);
import { navigateToCollection, navigateToNewEntry } from '../../routing/history';
export class Editor extends React.Component {
static propTypes = {
@ -169,16 +164,6 @@ export class Editor extends React.Component {
}
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) {
const confirmLoadBackup = window.confirm(this.props.t('editor.editor.confirmLoadBackup'));
if (confirmLoadBackup) {
@ -453,7 +438,7 @@ function mapStateToProps(state, ownProps) {
const collectionEntriesLoaded = !!entries.getIn(['pages', collectionName]);
const unPublishedEntry = selectUnpublishedEntry(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 localBackup = entryDraft.get('localBackup');
const draftKey = entryDraft.get('key');

View File

@ -20,6 +20,7 @@ import {
removeMediaControl,
} from 'Actions/mediaLibrary';
import Widget from './Widget';
import { validateMetaField } from '../../../actions/entries';
/**
* 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,
isNewEditorComponent: PropTypes.bool,
parentIds: PropTypes.arrayOf(PropTypes.string),
entry: ImmutablePropTypes.map.isRequired,
collection: ImmutablePropTypes.map.isRequired,
};
static defaultProps = {
@ -171,6 +174,7 @@ class EditorControl extends React.Component {
isNewEditorComponent,
parentIds,
t,
validateMetaField,
} = this.props;
const widgetName = field.get('widget');
@ -248,7 +252,7 @@ class EditorControl extends React.Component {
value={value}
mediaPaths={mediaPaths}
metadata={metadata}
onChange={(newValue, newMetadata) => onChange(fieldName, newValue, newMetadata)}
onChange={(newValue, newMetadata) => onChange(field, newValue, newMetadata)}
onValidate={onValidate && partial(onValidate, this.uniqueFieldId)}
onOpenMediaLibrary={openMediaLibrary}
onClearMediaControl={clearMediaControl}
@ -277,6 +281,7 @@ class EditorControl extends React.Component {
isNewEditorComponent={isNewEditorComponent}
parentIds={parentIds}
t={t}
validateMetaField={validateMetaField}
/>
{fieldHint && (
<ControlHint active={isSelected || this.state.styleActive} error={hasErrors}>
@ -311,10 +316,11 @@ const mapStateToProps = state => {
isFetching: state.search.get('isFetching'),
queryHits: state.search.get('queryHits'),
config: state.config,
collection,
entry,
collection,
isLoadingAsset,
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 (
<ControlPaneContainer>
{fields.map((field, i) =>
field.get('widget') === 'hidden' ? null : (
{fields.map((field, i) => {
return field.get('widget') === 'hidden' ? null : (
<EditorControl
key={i}
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}
fieldsErrors={fieldsErrors}
onChange={onChange}
onValidate={onValidate}
processControlRef={this.controlRef.bind(this)}
controlRef={this.controlRef}
entry={entry}
collection={collection}
/>
),
)}
);
})}
</ControlPaneContainer>
);
}

View File

@ -59,6 +59,7 @@ export default class Widget extends Component {
onValidateObject: PropTypes.func,
isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool,
entry: ImmutablePropTypes.map.isRequired,
};
shouldComponentUpdate(nextProps) {
@ -104,8 +105,11 @@ export default class Widget extends Component {
const field = this.props.field;
const errors = [];
const validations = [this.validatePresence, this.validatePattern];
if (field.get('meta')) {
validations.push(this.props.validateMetaField);
}
validations.forEach(func => {
const response = func(field, value);
const response = func(field, value, this.props.t);
if (response.error) errors.push(response.error);
});
if (skipWrapped) {
@ -114,6 +118,7 @@ export default class Widget extends Component {
const wrappedError = this.validateWrappedControl(field);
if (wrappedError.error) errors.push(wrappedError.error);
}
this.props.onValidate(errors);
};
@ -211,8 +216,8 @@ export default class Widget extends Component {
/**
* Change handler for fields that are nested within another field.
*/
onChangeObject = (fieldName, newValue, newMetadata) => {
const newObjectValue = this.getObjectValue().set(fieldName, newValue);
onChangeObject = (field, newValue, newMetadata) => {
const newObjectValue = this.getObjectValue().set(field.get('name'), newValue);
return this.props.onChange(
newObjectValue,
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.
let field = fields && fields.find(f => f.get('name') === 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 singleField = field.get('field');
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 { translate } from 'react-polyglot';
import { Map } from 'immutable';
import { Link } from 'react-router-dom';
import history from 'Routing/history';
import {
Icon,
Dropdown,
@ -80,7 +80,7 @@ const ToolbarSubSectionLast = styled(ToolbarSubSectionFirst)`
justify-content: flex-end;
`;
const ToolbarSectionBackLink = styled(Link)`
const ToolbarSectionBackLink = styled.a`
${styles.toolbarSection};
border-right-width: 1px;
font-weight: normal;
@ -568,7 +568,15 @@ class EditorToolbar extends React.Component {
return (
<ToolbarContainer>
<ToolbarSectionBackLink to={`/collections/${collection.get('name')}`}>
<ToolbarSectionBackLink
onClick={() => {
if (history.length > 0) {
history.goBack();
} else {
history.push(`/collections/${collection.get('name')}`);
}
}}
>
<BackArrow></BackArrow>
<div>
<BackCollection>

View File

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

View File

@ -316,5 +316,47 @@ describe('config', () => {
}).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,
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'],
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)]);
export const slugFormatter = (

View File

@ -449,4 +449,13 @@ export const selectFieldsComments = (collection: Collection, entryMap: EntryMap)
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;

View File

@ -98,12 +98,7 @@ const unpublishedEntries = (state = Map(), action: EditorialWorkflowAction) => {
// Update Optimistically
return state.withMutations(map => {
map.setIn(
[
'entities',
`${action.payload!.collection}.${action.payload!.slug}`,
'metaData',
'status',
],
['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'status'],
action.payload!.newStatus,
);
map.setIn(
@ -148,7 +143,7 @@ export const selectUnpublishedEntry = (
export const selectUnpublishedEntriesByStatus = (state: EditorialWorkflow, status: string) => {
if (!state) return null;
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) => {

View File

@ -351,6 +351,14 @@ export const selectEntries = (state: Entries, collection: Collection) => {
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) => {
return !!state.getIn(['pages', collection]);
};

View File

@ -22,6 +22,9 @@ import {
UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
UNPUBLISHED_ENTRY_PERSIST_FAILURE,
} from 'Actions/editorialWorkflow';
import { get } from 'lodash';
import { selectFolderEntryExtension, selectHasMetaPath } from './collections';
import { join } from 'path';
const initialState = Map({
entry: Map(),
@ -87,10 +90,22 @@ const entryDraftReducer = (state = Map(), action) => {
}
case DRAFT_CHANGE_FIELD: {
return state.withMutations(state => {
state.setIn(['entry', 'data', action.payload.field], action.payload.value);
state.mergeDeepIn(['fieldsMetaData'], fromJS(action.payload.metadata));
const { field, value, metadata, entries } = action.payload;
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']);
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:
@ -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;

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();
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;

View File

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

View File

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

View File

@ -7,11 +7,12 @@ interface Options {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?: any;
label?: string | null;
metaData?: unknown | null;
isModification?: boolean | null;
mediaFiles?: MediaFile[] | null;
author?: string;
updatedOn?: string;
status?: string;
meta?: { path?: string };
}
export interface EntryValue {
@ -23,11 +24,12 @@ export interface EntryValue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
label: string | null;
metaData: unknown | null;
isModification: boolean | null;
mediaFiles: MediaFile[];
author: string;
updatedOn: string;
status?: string;
meta: { path?: string };
}
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 || '',
data: options.data || {},
label: options.label || null,
metaData: options.metaData || null,
isModification: isBoolean(options.isModification) ? options.isModification : null,
mediaFiles: options.mediaFiles || [],
author: options.author || '',
updatedOn: options.updatedOn || '',
status: options.status || '',
meta: options.meta || {},
};
return returnObj;

View File

@ -26,13 +26,16 @@ export interface UnpublishedEntryMediaFile {
}
export interface ImplementationEntry {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: string;
file: { path: string; label?: string; id?: string | null; author?: string; updatedOn?: string };
slug?: string;
mediaFiles?: ImplementationMediaFile[];
metaData?: { collection: string; status: string };
isModification?: boolean;
}
export interface UnpublishedEntry {
slug: string;
collection: string;
status: string;
diffs: { id: string; path: string; newFile: boolean }[];
updatedAt: string;
}
export interface Map {
@ -48,7 +51,7 @@ export type AssetProxy = {
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 = {
newEntry?: boolean;
@ -116,8 +119,24 @@ export interface Implementation {
persistMedia: (file: AssetProxy, opts: PersistOptions) => Promise<ImplementationMediaFile>;
deleteFile: (path: string, commitMessage: string) => Promise<void>;
unpublishedEntries: () => Promise<ImplementationEntry[]>;
unpublishedEntry: (collection: string, slug: string) => Promise<ImplementationEntry>;
unpublishedEntries: () => Promise<string[]>;
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: (
collection: string,
slug: string,
@ -155,12 +174,6 @@ export type ImplementationFile = {
path: string;
};
type Metadata = {
objects: { entry: { path: string } };
collection: string;
status: string;
};
type ReadFile = (
path: string,
id: string | null | undefined,
@ -169,10 +182,6 @@ type ReadFile = (
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 (
files: ImplementationFile[],
readFile: ReadFile,
@ -206,47 +215,6 @@ const fetchFiles = async (
) 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 (
listFiles: () => Promise<ImplementationFile[]>,
readFile: ReadFile,
@ -266,15 +234,10 @@ export const entriesByFiles = async (
return fetchFiles(files, readFile, readFileMetadata, apiName);
};
export const unpublishedEntries = async (
listEntriesKeys: () => Promise<string[]>,
readUnpublishedFile: ReadUnpublishedFile,
apiName: string,
) => {
export const unpublishedEntries = async (listEntriesKeys: () => Promise<string[]>) => {
try {
const keys = await listEntriesKeys();
const entries = await fetchUnpublishedFiles(keys, readUnpublishedFile, apiName);
return entries;
return keys;
} catch (error) {
if (error.message === 'Not Found') {
return Promise.resolve([]);
@ -392,7 +355,6 @@ type GetDiffFromLocalTreeMethods = {
oldPath: string;
newPath: string;
status: string;
binary: boolean;
}[]
>;
filterFile: (file: { path: string; name: string }) => boolean;
@ -417,7 +379,7 @@ const getDiffFromLocalTree = async ({
}: GetDiffFromLocalTreeArgs) => {
const diff = await getDifferences(branch.sha, localTree.head);
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) => {
if (d.status === 'renamed') {
acc.push({

View File

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

View File

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

View File

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

View File

@ -5,8 +5,8 @@ import Joi from '@hapi/joi';
const assetFailure = (result: Joi.ValidationResult, expectedMessage: string) => {
const { error } = result;
expect(error).not.toBeNull();
expect(error.details).toHaveLength(1);
const message = error.details.map(({ message }) => message)[0];
expect(error!.details).toHaveLength(1);
const message = error!.details.map(({ message }) => message)[0];
expect(message).toBe(expectedMessage);
};
@ -26,7 +26,7 @@ describe('defaultSchema', () => {
assetFailure(
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', () => {
it('should fail on invalid params', () => {
const schema = defaultSchema();
assetFailure(
schema.validate({ action: 'unpublishedEntry', params: { ...defaultParams } }),
'"params.collection" 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',
schema.validate({ action: 'unpublishedEntry', params: {} }),
'"params.branch" is required',
);
});
it('should pass on valid params', () => {
it('should pass on valid collection and slug', () => {
const schema = defaultSchema();
const { error } = schema.validate({
action: 'unpublishedEntry',
@ -187,6 +172,66 @@ describe('defaultSchema', () => {
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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,6 +17,26 @@ export type GetEntryParams = {
};
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;
slug: string;
};
@ -32,7 +52,7 @@ export type PublishUnpublishedEntryParams = {
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' };

View File

@ -40,3 +40,19 @@ export const writeFile = async (filePath: string, content: Buffer | string) => {
export const deleteFile = async (repoPath: string, filePath: string) => {
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 components = {
card,
caretDown: css`
const caret = css`
color: ${colorsRaw.white};
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid currentColor;
border: 5px solid transparent;
border-radius: 2px;
`;
const components = {
card,
caretDown: css`
${caret};
border-top: 6px solid currentColor;
border-bottom: 0;
`,
caretRight: css`
${caret};
border-left: 6px solid currentColor;
border-right: 0;
`,
badge: css`
${backgroundBadge};

View File

@ -271,7 +271,7 @@ export default class ListControl extends React.Component {
getObjectValue = idx => this.props.value.get(idx) || Map();
handleChangeFor(index) {
return (fieldName, newValue, newMetadata) => {
return (f, newValue, newMetadata) => {
const { value, metadata, onChange, field } = this.props;
const collectionName = field.get('name');
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 && listFieldObjectWidget);
const newObjectValue = withNameKey
? this.getObjectValue(index).set(fieldName, newValue)
? this.getObjectValue(index).set(f.get('name'), newValue)
: newValue;
const parsedMetadata = {
[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`.
**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