feat: commit media with post (#2851)

* feat: commit media with post - initial commit

* feat: add draft media indication

* feat: sync UI media files with GitHub on entry load

* feat: bug fixes

* feat: delete media files from github when removed from library

* test: add GitHub backend tests

* test: add unit tests

* fix: meta data object files are not updated

* feat: used nested paths when update a tree instead of recursion

* feat(test-backend): update test backend to persist media file with entry

* test(e2e): re-record fixtures data

* chore: code cleanup

* chore: code cleanup

* fix: wait for library to load before adding entry media files

* chore: code cleanup

* fix: don't add media files on entry when not a draft

* fix: sync media library after draft entry was published

* feat: update media library card draft style, add tests

* test: add Editor unit tests

* chore: test code cleanup

* fix: publishing an entry from workflow tab throws an error

* fix: duplicate media files when using test backend

* refactor: fix lodash import

* chore: update translations and yarn file after rebase

* test(cypress): update recorded data

* fix(test-backend): fix mapping of media files on publish
This commit is contained in:
Erez Rokah
2019-11-17 11:51:50 +02:00
committed by GitHub
parent 0898767fc9
commit 6515dee871
77 changed files with 17692 additions and 27991 deletions

View File

@ -1,6 +1,17 @@
import { Base64 } from 'js-base64';
import semaphore from 'semaphore';
import { find, flow, get, hasIn, initial, last, partial, result, uniq } from 'lodash';
import {
find,
flow,
get,
hasIn,
initial,
last,
partial,
result,
differenceBy,
trimStart,
} from 'lodash';
import { map } from 'lodash/fp';
import {
getAllResponses,
@ -195,15 +206,10 @@ export default class API {
this._metadataSemaphore.take(async () => {
try {
const branchData = await this.checkMetadataRef();
const fileTree = {
[`${key}.json`]: {
path: `${key}.json`,
raw: JSON.stringify(data),
file: true,
},
};
await this.uploadBlob(fileTree[`${key}.json`]);
const changeTree = await this.updateTree(branchData.sha, '/', fileTree);
const file = { path: `${key}.json`, raw: JSON.stringify(data) };
await this.uploadBlob(file);
const changeTree = await this.updateTree(branchData.sha, [file]);
const { sha } = await this.commit(`Updating “${key}” metadata`, changeTree);
await this.patchRef('meta', '_netlify_cms', sha);
localForage.setItem(`gh.meta.${key}`, {
@ -304,7 +310,7 @@ export default class API {
return text;
}
async getMediaDisplayURL(sha, path) {
async getMediaAsBlob(sha, path) {
const response = await this.fetchBlob(sha, this.repoURL);
let blob;
if (path.match(/.svg$/)) {
@ -313,6 +319,11 @@ export default class API {
} else {
blob = await response.blob();
}
return blob;
}
async getMediaDisplayURL(sha, path) {
const blob = await this.getMediaAsBlob(sha, path);
return URL.createObjectURL(blob);
}
@ -501,56 +512,23 @@ export default class API {
}
}
composeFileTree(files) {
let filename;
let part;
let parts;
let subtree;
const fileTree = {};
files.forEach(file => {
if (file.uploaded) {
return;
}
parts = file.path.split('/').filter(part => part);
filename = parts.pop();
subtree = fileTree;
while ((part = parts.shift())) {
// eslint-disable-line no-cond-assign
subtree[part] = subtree[part] || {};
subtree = subtree[part];
}
subtree[filename] = file;
file.file = true;
});
return fileTree;
}
persistFiles(entry, mediaFiles, options) {
const uploadPromises = [];
async persistFiles(entry, mediaFiles, options) {
const files = entry ? mediaFiles.concat(entry) : mediaFiles;
const uploadPromises = files.filter(file => !file.uploaded).map(file => this.uploadBlob(file));
await Promise.all(uploadPromises);
files.forEach(file => {
if (file.uploaded) {
return;
}
uploadPromises.push(this.uploadBlob(file));
});
const fileTree = this.composeFileTree(files);
return Promise.all(uploadPromises).then(() => {
if (!options.useWorkflow) {
return this.getBranch()
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(response => this.patchBranch(this.branch, response.sha));
} else {
const mediaFilesList = mediaFiles.map(file => ({ path: file.path, sha: file.sha }));
return this.editorialWorkflowGit(fileTree, entry, mediaFilesList, options);
}
});
if (!options.useWorkflow) {
return this.getBranch()
.then(branchData => this.updateTree(branchData.commit.sha, files))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(response => this.patchBranch(this.branch, response.sha));
} else {
const mediaFilesList = mediaFiles.map(({ sha, path }) => ({
path: trimStart(path, '/'),
sha,
}));
return this.editorialWorkflowGit(files, entry, mediaFilesList, options);
}
}
getFileSha(path, branch) {
@ -597,7 +575,7 @@ export default class API {
return this.createPR(commitMessage, branchName);
}
async editorialWorkflowGit(fileTree, entry, filesList, options) {
async editorialWorkflowGit(files, entry, mediaFilesList, options) {
const contentKey = this.generateContentKey(options.collectionName, entry.slug);
const branchName = this.generateBranchName(contentKey);
const unpublished = options.unpublished || false;
@ -605,7 +583,7 @@ export default class API {
// Open new editorial review workflow for this entry - Create new metadata and commit to new branch
const userPromise = this.user();
const branchData = await this.getBranch();
const changeTree = await this.updateTree(branchData.commit.sha, '/', fileTree);
const changeTree = await this.updateTree(branchData.commit.sha, files);
const commitResponse = await this.commit(options.commitMessage, changeTree);
let pr;
@ -640,24 +618,30 @@ export default class API {
path: entry.path,
sha: entry.sha,
},
files: filesList,
files: mediaFilesList,
},
timeStamp: new Date().toISOString(),
});
} else {
// Entry is already on editorial review workflow - just update metadata and commit to existing branch
const metadata = await this.retrieveMetadata(contentKey);
// mark media files to remove
const metadataMediaFiles = get(metadata, 'objects.files', []);
const mediaFilesToRemove = differenceBy(metadataMediaFiles, mediaFilesList, 'path').map(
file => ({ ...file, remove: true }),
);
const branchData = await this.getBranch(branchName);
const changeTree = await this.updateTree(branchData.commit.sha, '/', fileTree);
const commitPromise = this.commit(options.commitMessage, changeTree);
const metadataPromise = this.retrieveMetadata(contentKey);
const [commit, metadata] = await Promise.all([commitPromise, metadataPromise]);
const changeTree = await this.updateTree(
branchData.commit.sha,
files.concat(mediaFilesToRemove),
);
const commit = await this.commit(options.commitMessage, changeTree);
const { title, description } = options.parsedData || {};
const metadataFiles = get(metadata.objects, 'files', []);
const files = [...metadataFiles, ...filesList];
const pr = metadata.pr ? { ...metadata.pr, head: commit.sha } : undefined;
const objects = {
entry: { path: entry.path, sha: entry.sha },
files: uniq(files),
files: mediaFilesList,
};
const updatedMetadata = { ...metadata, pr, title, description, objects };
@ -667,7 +651,7 @@ export default class API {
}
if (pr) {
return this.rebasePullRequest(pr.number, branchName, contentKey, metadata, commit);
return this.rebasePullRequest(pr.number, branchName, contentKey, updatedMetadata, commit);
} else if (this.useOpenAuthoring) {
// if a PR hasn't been created yet for the forked repo, just patch the branch
await this.patchBranch(branchName, commit.sha, { force: true });
@ -692,7 +676,7 @@ export default class API {
*/
const [baseBranch, commits] = await Promise.all([
this.getBranch(),
this.getPullRequestCommits(prNumber, head),
this.getPullRequestCommits(prNumber),
]);
/**
@ -891,12 +875,14 @@ export default class API {
);
}
publishUnpublishedEntry(collectionName, slug) {
async publishUnpublishedEntry(collectionName, slug) {
const contentKey = this.generateContentKey(collectionName, slug);
const branchName = this.generateBranchName(contentKey);
return this.retrieveMetadata(contentKey)
.then(metadata => this.mergePR(metadata.pr, metadata.objects))
.then(() => this.deleteBranch(branchName));
const metadata = await this.retrieveMetadata(contentKey);
await this.mergePR(metadata.pr, metadata.objects);
await this.deleteBranch(branchName);
return metadata;
}
createRef(type, name, sha) {
@ -1000,7 +986,6 @@ export default class API {
forceMergePR(pullrequest, objects) {
const files = objects.files.concat(objects.entry);
const fileTree = this.composeFileTree(files);
let commitMessage = 'Automatically generated. Merged on Netlify CMS\n\nForce merge of:';
files.forEach(file => {
commitMessage += `\n* "${file.path}"`;
@ -1010,7 +995,7 @@ export default class API {
'line-height: 30px;text-align: center;font-weight: bold',
);
return this.getBranch()
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
.then(branchData => this.updateTree(branchData.commit.sha, files))
.then(changeTree => this.commit(commitMessage, changeTree))
.then(response => this.patchBranch(this.branch, response.sha));
}
@ -1062,47 +1047,17 @@ export default class API {
);
}
updateTree(sha, path, fileTree) {
return this.getTree(sha).then(tree => {
let obj;
let filename;
let fileOrDir;
const updates = [];
const added = {};
async updateTree(sha, files) {
const tree = files.map(file => ({
path: trimStart(file.path, '/'),
mode: '100644',
type: 'blob',
sha: file.remove ? null : file.sha,
}));
for (let i = 0, len = tree.tree.length; i < len; i++) {
obj = tree.tree[i];
if ((fileOrDir = fileTree[obj.path])) {
// eslint-disable-line no-cond-assign
added[obj.path] = true;
if (fileOrDir.file) {
updates.push({ path: obj.path, mode: obj.mode, type: obj.type, sha: fileOrDir.sha });
} else {
updates.push(this.updateTree(obj.sha, obj.path, fileOrDir));
}
}
}
for (filename in fileTree) {
fileOrDir = fileTree[filename];
if (added[filename]) {
continue;
}
updates.push(
fileOrDir.file
? { path: filename, mode: '100644', type: 'blob', sha: fileOrDir.sha }
: this.updateTree(null, filename, fileOrDir),
);
}
return Promise.all(updates)
.then(tree => this.createTree(sha, tree))
.then(response => ({
path,
mode: '040000',
type: 'tree',
sha: response.sha,
parentSha: sha,
}));
});
const newTree = await this.createTree(sha, tree);
newTree.parentSha = sha;
return newTree;
}
createTree(baseSha, tree) {

View File

@ -1,40 +1,85 @@
import { Base64 } from 'js-base64';
import API from '../API';
global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch inside tests'));
describe('github API', () => {
beforeEach(() => {
jest.resetAllMocks();
});
const mockAPI = (api, responses) => {
api.request = (path, options = {}) => {
api.request = jest.fn().mockImplementation((path, options = {}) => {
const normalizedPath = path.indexOf('?') !== -1 ? path.substr(0, path.indexOf('?')) : path;
const response = responses[normalizedPath];
return typeof response === 'function'
? Promise.resolve(response(options))
: Promise.reject(new Error(`No response for path '${normalizedPath}'`));
};
});
};
it('should create PR with correct base branch name when publishing with editorial workflow', () => {
let prBaseBranch = null;
const api = new API({ branch: 'gh-pages', repo: 'my-repo' });
const responses = {
'/repos/my-repo/branches/gh-pages': () => ({ commit: { sha: 'def' } }),
'/repos/my-repo/git/trees/def': () => ({ tree: [] }),
'/repos/my-repo/git/trees': () => ({}),
'/repos/my-repo/git/commits': () => ({}),
'/repos/my-repo/git/refs': () => ({}),
'/repos/my-repo/pulls': pullRequest => {
prBaseBranch = JSON.parse(pullRequest.body).base;
return { head: { sha: 'cbd' } };
},
'/user': () => ({}),
'/repos/my-repo/git/blobs': () => ({}),
'/repos/my-repo/git/refs/meta/_netlify_cms': () => ({ object: {} }),
};
mockAPI(api, responses);
describe('editorialWorkflowGit', () => {
it('should create PR with correct base branch name when publishing with editorial workflow', () => {
let prBaseBranch = null;
const api = new API({ branch: 'gh-pages', repo: 'my-repo' });
const responses = {
'/repos/my-repo/branches/gh-pages': () => ({ commit: { sha: 'def' } }),
'/repos/my-repo/git/trees/def': () => ({ tree: [] }),
'/repos/my-repo/git/trees': () => ({}),
'/repos/my-repo/git/commits': () => ({}),
'/repos/my-repo/git/refs': () => ({}),
'/repos/my-repo/pulls': pullRequest => {
prBaseBranch = JSON.parse(pullRequest.body).base;
return { head: { sha: 'cbd' } };
},
'/user': () => ({}),
'/repos/my-repo/git/blobs': () => ({}),
'/repos/my-repo/git/refs/meta/_netlify_cms': () => ({ object: {} }),
};
mockAPI(api, responses);
return expect(
api
.editorialWorkflowGit(null, { slug: 'entry', sha: 'abc' }, null, {})
.then(() => prBaseBranch),
).resolves.toEqual('gh-pages');
return expect(
api
.editorialWorkflowGit([], { slug: 'entry', sha: 'abc' }, null, {})
.then(() => prBaseBranch),
).resolves.toEqual('gh-pages');
});
});
describe('updateTree', () => {
it('should create tree with nested paths', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
api.createTree = jest.fn().mockImplementation(() => Promise.resolve({ sha: 'newTreeSha' }));
const files = [
{ path: '/static/media/new-image.jpeg', sha: 'new-image.jpeg', remove: true },
{ path: 'content/posts/new-post.md', sha: 'new-post.md' },
];
const baseTreeSha = 'baseTreeSha';
await expect(api.updateTree(baseTreeSha, files)).resolves.toEqual({
sha: 'newTreeSha',
parentSha: baseTreeSha,
});
expect(api.createTree).toHaveBeenCalledTimes(1);
expect(api.createTree).toHaveBeenCalledWith(baseTreeSha, [
{
path: 'static/media/new-image.jpeg',
mode: '100644',
type: 'blob',
sha: null,
},
{
path: 'content/posts/new-post.md',
mode: '100644',
type: 'blob',
sha: 'new-post.md',
},
]);
});
});
describe('request', () => {
@ -106,4 +151,191 @@ describe('github API', () => {
});
});
});
describe('getMediaAsBlob', () => {
it('should return response blob on non svg file', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const blob = {};
const response = { blob: jest.fn().mockResolvedValue(blob) };
api.fetchBlob = jest.fn().mockResolvedValue(response);
await expect(api.getMediaAsBlob('sha', 'static/media/image.png')).resolves.toBe(blob);
expect(api.fetchBlob).toHaveBeenCalledTimes(1);
expect(api.fetchBlob).toHaveBeenCalledWith('sha', '/repos/owner/repo');
expect(response.blob).toHaveBeenCalledTimes(1);
});
it('should return test blob on non file', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const response = { text: jest.fn().mockResolvedValue('svg') };
api.fetchBlob = jest.fn().mockResolvedValue(response);
await expect(api.getMediaAsBlob('sha', 'static/media/logo.svg')).resolves.toEqual(
new Blob(['svg'], { type: 'image/svg+xml' }),
);
expect(api.fetchBlob).toHaveBeenCalledTimes(1);
expect(api.fetchBlob).toHaveBeenCalledWith('sha', '/repos/owner/repo');
expect(response.text).toHaveBeenCalledTimes(1);
});
});
describe('getMediaDisplayURL', () => {
it('should return createObjectURL result', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const blob = {};
api.getMediaAsBlob = jest.fn().mockResolvedValue(blob);
global.URL.createObjectURL = jest
.fn()
.mockResolvedValue('blob:http://localhost:8080/blob-id');
await expect(api.getMediaDisplayURL('sha', 'static/media/image.png')).resolves.toBe(
'blob:http://localhost:8080/blob-id',
);
expect(api.getMediaAsBlob).toHaveBeenCalledTimes(1);
expect(api.getMediaAsBlob).toHaveBeenCalledWith('sha', 'static/media/image.png');
expect(global.URL.createObjectURL).toHaveBeenCalledTimes(1);
expect(global.URL.createObjectURL).toHaveBeenCalledWith(blob);
});
});
describe('persistFiles', () => {
it('should update tree, commit and patch branch when useWorkflow is false', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const responses = {
// upload the file
'/repos/owner/repo/git/blobs': () => ({ sha: 'new-file-sha' }),
// get the branch
'/repos/owner/repo/branches/master': () => ({ commit: { sha: 'root' } }),
// create new tree
'/repos/owner/repo/git/trees': options => {
const data = JSON.parse(options.body);
return { sha: data.base_tree };
},
// update the commit with the tree
'/repos/owner/repo/git/commits': () => ({ sha: 'commit-sha' }),
// patch the branch
'/repos/owner/repo/git/refs/heads/master': () => ({}),
};
mockAPI(api, responses);
const entry = {
slug: 'entry',
sha: 'abc',
path: 'content/posts/new-post.md',
raw: 'content',
};
await api.persistFiles(entry, [], { commitMessage: 'commitMessage' });
expect(api.request).toHaveBeenCalledTimes(5);
expect(api.request.mock.calls[0]).toEqual([
'/repos/owner/repo/git/blobs',
{
method: 'POST',
body: JSON.stringify({ content: Base64.encode(entry.raw), encoding: 'base64' }),
},
]);
expect(api.request.mock.calls[1]).toEqual(['/repos/owner/repo/branches/master']);
expect(api.request.mock.calls[2]).toEqual([
'/repos/owner/repo/git/trees',
{
body: JSON.stringify({
base_tree: 'root',
tree: [
{
path: 'content/posts/new-post.md',
mode: '100644',
type: 'blob',
sha: 'new-file-sha',
},
],
}),
method: 'POST',
},
]);
expect(api.request.mock.calls[3]).toEqual([
'/repos/owner/repo/git/commits',
{
body: JSON.stringify({
message: 'commitMessage',
tree: 'root',
parents: ['root'],
}),
method: 'POST',
},
]);
expect(api.request.mock.calls[4]).toEqual([
'/repos/owner/repo/git/refs/heads/master',
{
body: JSON.stringify({
sha: 'commit-sha',
force: false,
}),
method: 'PATCH',
},
]);
});
it('should call editorialWorkflowGit when useWorkflow is true', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
api.uploadBlob = jest.fn();
api.editorialWorkflowGit = jest.fn();
const entry = {
slug: 'entry',
sha: 'abc',
path: 'content/posts/new-post.md',
raw: 'content',
};
const mediaFiles = [
{
path: '/static/media/image-1.png',
uploaded: true,
sha: 'image-1.png',
},
{
path: '/static/media/image-2.png',
sha: 'image-2.png',
},
];
await api.persistFiles(entry, mediaFiles, { useWorkflow: true });
expect(api.uploadBlob).toHaveBeenCalledTimes(2);
expect(api.uploadBlob).toHaveBeenCalledWith(entry);
expect(api.uploadBlob).toHaveBeenCalledWith(mediaFiles[1]);
expect(api.editorialWorkflowGit).toHaveBeenCalledTimes(1);
expect(api.editorialWorkflowGit).toHaveBeenCalledWith(
mediaFiles.concat(entry),
entry,
[
{ path: 'static/media/image-1.png', sha: 'image-1.png' },
{ path: 'static/media/image-2.png', sha: 'image-2.png' },
],
{ useWorkflow: true },
);
});
});
});

View File

@ -20,6 +20,13 @@ describe('github backend implementation', () => {
}),
};
const createObjectURL = jest.fn();
global.URL = {
createObjectURL,
};
createObjectURL.mockReturnValue('displayURL');
beforeEach(() => {
jest.clearAllMocks();
});
@ -72,4 +79,173 @@ describe('github backend implementation', () => {
await expect(gitHubImplementation.forkExists({ token: 'token' })).resolves.toBe(false);
});
});
describe('persistMedia', () => {
const persistFiles = jest.fn();
const mockAPI = {
persistFiles,
};
persistFiles.mockImplementation((_, files) => {
files.forEach((file, index) => {
file.sha = index;
});
});
it('should persist media file when not draft', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
const mediaFile = {
value: 'image.png',
fileObj: { size: 100 },
path: '/media/image.png',
};
expect.assertions(5);
await expect(gitHubImplementation.persistMedia(mediaFile)).resolves.toEqual({
id: 0,
name: 'image.png',
size: 100,
displayURL: 'displayURL',
path: 'media/image.png',
draft: undefined,
});
expect(persistFiles).toHaveBeenCalledTimes(1);
expect(persistFiles).toHaveBeenCalledWith(null, [mediaFile], {});
expect(createObjectURL).toHaveBeenCalledTimes(1);
expect(createObjectURL).toHaveBeenCalledWith(mediaFile.fileObj);
});
it('should not persist media file when draft', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
createObjectURL.mockReturnValue('displayURL');
const mediaFile = {
value: 'image.png',
fileObj: { size: 100 },
path: '/media/image.png',
};
expect.assertions(4);
await expect(gitHubImplementation.persistMedia(mediaFile, { draft: true })).resolves.toEqual({
id: undefined,
name: 'image.png',
size: 100,
displayURL: 'displayURL',
path: 'media/image.png',
draft: true,
});
expect(persistFiles).toHaveBeenCalledTimes(0);
expect(createObjectURL).toHaveBeenCalledTimes(1);
expect(createObjectURL).toHaveBeenCalledWith(mediaFile.fileObj);
});
it('should log and throw error on "persistFiles" error', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
const error = new Error('failed to persist files');
persistFiles.mockRejectedValue(error);
const mediaFile = {
value: 'image.png',
fileObj: { size: 100 },
path: '/media/image.png',
};
expect.assertions(5);
await expect(gitHubImplementation.persistMedia(mediaFile)).rejects.toThrowError(error);
expect(persistFiles).toHaveBeenCalledTimes(1);
expect(createObjectURL).toHaveBeenCalledTimes(0);
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(error);
});
});
describe('getMediaFiles', () => {
const getMediaAsBlob = jest.fn();
const mockAPI = {
getMediaAsBlob,
};
it('should return media files from meta data', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
const blob = new Blob(['']);
getMediaAsBlob.mockResolvedValue(blob);
const file = new File([blob], name);
const data = {
metaData: {
objects: {
files: [{ path: 'static/media/image.png', sha: 'image.png' }],
},
},
};
await expect(gitHubImplementation.getMediaFiles(data)).resolves.toEqual([
{
id: 'image.png',
sha: 'image.png',
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 mockAPI = {
generateContentKey,
readUnpublishedBranchFile,
};
it('should return unpublished entry', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
gitHubImplementation.getMediaFiles = jest.fn().mockResolvedValue([{ path: 'image.png' }]);
generateContentKey.mockReturnValue('contentKey');
const data = {
fileData: 'fileData',
isModification: true,
metaData: { objects: { entry: { path: 'entry-path' } } },
};
readUnpublishedBranchFile.mockResolvedValue(data);
const collection = { get: jest.fn().mockReturnValue('posts') };
await expect(gitHubImplementation.unpublishedEntry(collection, 'slug')).resolves.toEqual({
slug: 'slug',
file: { path: 'entry-path' },
data: 'fileData',
metaData: { objects: { entry: { path: 'entry-path' } } },
mediaFiles: [{ path: 'image.png' }],
isModification: true,
});
expect(generateContentKey).toHaveBeenCalledTimes(1);
expect(generateContentKey).toHaveBeenCalledWith('posts', 'slug');
expect(readUnpublishedBranchFile).toHaveBeenCalledTimes(1);
expect(readUnpublishedBranchFile).toHaveBeenCalledWith('contentKey');
expect(gitHubImplementation.getMediaFiles).toHaveBeenCalledTimes(1);
expect(gitHubImplementation.getMediaFiles).toHaveBeenCalledWith(data);
});
});
});

View File

@ -4,6 +4,7 @@ import semaphore from 'semaphore';
import { stripIndent } from 'common-tags';
import { asyncLock } from 'netlify-cms-lib-util';
import AuthenticationPage from './AuthenticationPage';
import { get } from 'lodash';
import API from './API';
import GraphQLAPI from './GraphQLAPI';
@ -331,7 +332,9 @@ export default class GitHub {
async persistMedia(mediaFile, options = {}) {
try {
await this.api.persistFiles(null, [mediaFile], options);
if (!options.draft) {
await this.api.persistFiles(null, [mediaFile], options);
}
const { sha, value, path, fileObj } = mediaFile;
const displayURL = URL.createObjectURL(fileObj);
@ -341,6 +344,7 @@ export default class GitHub {
size: fileObj.size,
displayURL,
path: trimStart(path, '/'),
draft: options.draft,
};
} catch (error) {
console.error(error);
@ -352,6 +356,29 @@ export default class GitHub {
return this.api.deleteFile(path, commitMessage, options);
}
async getMediaFiles(data) {
const files = get(data, 'metaData.objects.files', []);
const mediaFiles = await Promise.all(
files.map(file =>
this.api.getMediaAsBlob(file.sha, file.path).then(blob => {
const name = file.path.substring(file.path.lastIndexOf('/') + 1);
const fileObj = new File([blob], name);
return {
id: file.sha,
sha: file.sha,
displayURL: URL.createObjectURL(fileObj),
path: file.path,
name: name,
size: fileObj.size,
file: fileObj,
};
}),
),
);
return mediaFiles;
}
unpublishedEntries() {
return this.api
.listUnpublishedBranches()
@ -371,10 +398,9 @@ export default class GitHub {
resolve(null);
sem.leave();
} else {
const path = data.metaData.objects.entry.path;
resolve({
slug,
file: { path },
file: { path: data.metaData.objects.entry.path },
data: data.fileData,
metaData: data.metaData,
isModification: data.isModification,
@ -400,18 +426,21 @@ export default class GitHub {
});
}
unpublishedEntry(collection, slug) {
async unpublishedEntry(collection, slug) {
const contentKey = this.api.generateContentKey(collection.get('name'), slug);
return this.api.readUnpublishedBranchFile(contentKey).then(data => {
if (!data) return null;
return {
slug,
file: { path: data.metaData.objects.entry.path },
data: data.fileData,
metaData: data.metaData,
isModification: data.isModification,
};
});
const data = await this.api.readUnpublishedBranchFile(contentKey);
if (!data) {
return null;
}
const mediaFiles = await this.getMediaFiles(data);
return {
slug,
file: { path: data.metaData.objects.entry.path },
data: data.fileData,
metaData: data.metaData,
mediaFiles,
isModification: data.isModification,
};
}
/**
@ -456,9 +485,10 @@ export default class GitHub {
publishUnpublishedEntry(collection, slug) {
// publishUnpublishedEntry is a transactional operation
return this.runWithLock(
() => this.api.publishUnpublishedEntry(collection, slug),
'Failed to acquire publish entry lock',
);
return this.runWithLock(async () => {
const metaData = await this.api.publishUnpublishedEntry(collection, slug);
const mediaFiles = await this.getMediaFiles({ metaData });
return { mediaFiles };
}, 'Failed to acquire publish entry lock');
}
}