feat(backend-github): Open Authoring (#2430)
* Make filterPromises resolve entries before filtering * Add filterPromisesWith & onlySuccessfulPromises to utility library * Memoize user method in GitHub API * Make storeMetadata safe to call concurrently in GitHub API * Fork workflow: startup and authentication * Fork workflow: backend support * Fork workflow: disable unused UI elements * Fork workflow: docs * Fork workflow: fix deploy previews * Suggested edits for fork workflow doc * Change future tense to present * Fork workflow: add beta status to docs * remove debug statement * rename fork workflow to Open Authoring
This commit is contained in:
committed by
Shawn Erquhart
parent
41559256d0
commit
edf0a3afdc
@ -1,28 +1,39 @@
|
||||
import { Base64 } from 'js-base64';
|
||||
import { uniq, initial, last, get, find, hasIn, partial, result } from 'lodash';
|
||||
import semaphore from 'semaphore';
|
||||
import { find, flow, get, hasIn, initial, last, partial, result, uniq } from 'lodash';
|
||||
import { map } from 'lodash/fp';
|
||||
import {
|
||||
localForage,
|
||||
filterPromises,
|
||||
resolvePromiseProperties,
|
||||
APIError,
|
||||
EditorialWorkflowError,
|
||||
filterPromisesWith,
|
||||
localForage,
|
||||
onlySuccessfulPromises,
|
||||
resolvePromiseProperties,
|
||||
} from 'netlify-cms-lib-util';
|
||||
|
||||
const CMS_BRANCH_PREFIX = 'cms/';
|
||||
|
||||
const replace404WithEmptyArray = err => (err && err.status === 404 ? [] : Promise.reject(err));
|
||||
|
||||
export default class API {
|
||||
constructor(config) {
|
||||
this.api_root = config.api_root || 'https://api.github.com';
|
||||
this.token = config.token || false;
|
||||
this.branch = config.branch || 'master';
|
||||
this.originRepo = config.originRepo;
|
||||
this.useForkWorkflow = config.useForkWorkflow;
|
||||
this.repo = config.repo || '';
|
||||
this.repoURL = `/repos/${this.repo}`;
|
||||
this.originRepoURL = this.originRepo && `/repos/${this.originRepo}`;
|
||||
this.merge_method = config.squash_merges ? 'squash' : 'merge';
|
||||
this.initialWorkflowStatus = config.initialWorkflowStatus;
|
||||
}
|
||||
|
||||
user() {
|
||||
return this.request('/user');
|
||||
if (!this._userPromise) {
|
||||
this._userPromise = this.request('/user');
|
||||
}
|
||||
return this._userPromise;
|
||||
}
|
||||
|
||||
hasWriteAccess() {
|
||||
@ -94,8 +105,32 @@ export default class API {
|
||||
});
|
||||
}
|
||||
|
||||
generateBranchName(basename) {
|
||||
return `${CMS_BRANCH_PREFIX}${basename}`;
|
||||
generateContentKey(collectionName, slug) {
|
||||
if (!this.useForkWorkflow) {
|
||||
// this doesn't use the collection, but we need to leave it that way for backwards
|
||||
// compatibility
|
||||
return slug;
|
||||
}
|
||||
|
||||
return `${this.repo}/${collectionName}/${slug}`;
|
||||
}
|
||||
|
||||
generateBranchNameFromCollectionAndSlug(collectionName, slug) {
|
||||
return this.generateContentKey(collectionName, slug).then(contentKey =>
|
||||
this.generateBranchName(contentKey),
|
||||
);
|
||||
}
|
||||
|
||||
generateBranchName(contentKey) {
|
||||
return `${CMS_BRANCH_PREFIX}${contentKey}`;
|
||||
}
|
||||
|
||||
branchNameFromRef(ref) {
|
||||
return ref.substring('refs/heads/'.length - 1);
|
||||
}
|
||||
|
||||
contentKeyFromRef(ref) {
|
||||
return ref.substring(`refs/heads/${CMS_BRANCH_PREFIX}/`.length - 1);
|
||||
}
|
||||
|
||||
checkMetadataRef() {
|
||||
@ -125,27 +160,39 @@ export default class API {
|
||||
});
|
||||
}
|
||||
|
||||
storeMetadata(key, data) {
|
||||
return this.checkMetadataRef().then(branchData => {
|
||||
const fileTree = {
|
||||
[`${key}.json`]: {
|
||||
path: `${key}.json`,
|
||||
raw: JSON.stringify(data),
|
||||
file: true,
|
||||
},
|
||||
};
|
||||
|
||||
return this.uploadBlob(fileTree[`${key}.json`])
|
||||
.then(() => this.updateTree(branchData.sha, '/', fileTree))
|
||||
.then(changeTree => this.commit(`Updating “${key}” metadata`, changeTree))
|
||||
.then(response => this.patchRef('meta', '_netlify_cms', response.sha))
|
||||
.then(() => {
|
||||
async storeMetadata(key, data) {
|
||||
// semaphore ensures metadata updates are always ordered, even if
|
||||
// calls to storeMetadata are not. concurrent metadata updates
|
||||
// will result in the metadata branch being unable to update.
|
||||
if (!this._metadataSemaphore) {
|
||||
this._metadataSemaphore = semaphore(1);
|
||||
}
|
||||
return new Promise((resolve, reject) =>
|
||||
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 { sha } = await this.commit(`Updating “${key}” metadata`, changeTree);
|
||||
await this.patchRef('meta', '_netlify_cms', sha);
|
||||
localForage.setItem(`gh.meta.${key}`, {
|
||||
expires: Date.now() + 300000, // In 5 minutes
|
||||
data,
|
||||
});
|
||||
});
|
||||
});
|
||||
this._metadataSemaphore.leave();
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
retrieveMetadata(key) {
|
||||
@ -158,11 +205,27 @@ export default class API {
|
||||
'%c Checking for MetaData files',
|
||||
'line-height: 30px;text-align: center;font-weight: bold',
|
||||
);
|
||||
return this.request(`${this.repoURL}/contents/${key}.json`, {
|
||||
|
||||
const metadataRequestOptions = {
|
||||
params: { ref: 'refs/meta/_netlify_cms' },
|
||||
headers: { Accept: 'application/vnd.github.VERSION.raw' },
|
||||
cache: 'no-store',
|
||||
})
|
||||
};
|
||||
|
||||
if (!this.useForkWorkflow) {
|
||||
return this.request(`${this.repoURL}/contents/${key}.json`, metadataRequestOptions)
|
||||
.then(response => JSON.parse(response))
|
||||
.catch(() =>
|
||||
console.log(
|
||||
'%c %s does not have metadata',
|
||||
'line-height: 30px;text-align: center;font-weight: bold',
|
||||
key,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const [user, repo] = key.split('/');
|
||||
return this.request(`/repos/${user}/${repo}/contents/${key}.json`, metadataRequestOptions)
|
||||
.then(response => JSON.parse(response))
|
||||
.catch(() =>
|
||||
console.log(
|
||||
@ -174,11 +237,11 @@ export default class API {
|
||||
});
|
||||
}
|
||||
|
||||
readFile(path, sha, branch = this.branch) {
|
||||
readFile(path, sha, { branch = this.branch, repoURL = this.repoURL } = {}) {
|
||||
if (sha) {
|
||||
return this.getBlob(sha);
|
||||
} else {
|
||||
return this.request(`${this.repoURL}/contents/${path}`, {
|
||||
return this.request(`${repoURL}/contents/${path}`, {
|
||||
headers: { Accept: 'application/vnd.github.VERSION.raw' },
|
||||
params: { ref: branch },
|
||||
cache: 'no-store',
|
||||
@ -229,10 +292,19 @@ export default class API {
|
||||
const metaDataPromise = this.retrieveMetadata(contentKey).then(data =>
|
||||
data.objects.entry.path ? data : Promise.reject(null),
|
||||
);
|
||||
const repoURL = this.useForkWorkflow
|
||||
? `/repos/${contentKey
|
||||
.split('/')
|
||||
.slice(0, 2)
|
||||
.join('/')}`
|
||||
: this.repoURL;
|
||||
return resolvePromiseProperties({
|
||||
metaData: metaDataPromise,
|
||||
fileData: metaDataPromise.then(data =>
|
||||
this.readFile(data.objects.entry.path, null, data.branch),
|
||||
this.readFile(data.objects.entry.path, null, {
|
||||
branch: data.branch,
|
||||
repoURL,
|
||||
}),
|
||||
),
|
||||
isModification: metaDataPromise.then(data =>
|
||||
this.isUnpublishedEntryModification(data.objects.entry.path, this.branch),
|
||||
@ -243,7 +315,10 @@ export default class API {
|
||||
}
|
||||
|
||||
isUnpublishedEntryModification(path, branch) {
|
||||
return this.readFile(path, null, branch)
|
||||
return this.readFile(path, null, {
|
||||
branch,
|
||||
repoURL: this.useForkWorkflow ? this.originRepoURL : this.repoURL,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(err => {
|
||||
if (err.message && err.message === 'Not Found') {
|
||||
@ -253,35 +328,107 @@ export default class API {
|
||||
});
|
||||
}
|
||||
|
||||
listUnpublishedBranches() {
|
||||
getPRsForBranchName = ({
|
||||
branchName,
|
||||
state,
|
||||
base = this.branch,
|
||||
repoURL = this.repoURL,
|
||||
usernameOfFork,
|
||||
} = {}) => {
|
||||
// Get PRs with a `head` of `branchName`. Note that this is a
|
||||
// substring match, so we need to check that the `head.ref` of
|
||||
// at least one of the returned objects matches `branchName`.
|
||||
// TODO: this is a paginated endpoint
|
||||
return this.request(`${repoURL}/pulls`, {
|
||||
params: {
|
||||
head: usernameOfFork ? `${usernameOfFork}:${branchName}` : branchName,
|
||||
...(state ? { state } : {}),
|
||||
base,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
branchHasPR = async ({ branchName, ...rest }) => {
|
||||
const prs = await this.getPRsForBranchName({ branchName, ...rest });
|
||||
return prs.some(pr => this.branchNameFromRef(pr.head.ref) === branchName);
|
||||
};
|
||||
|
||||
getUpdatedForkWorkflowMetadata = async (contentKey, { metadata: metadataArg } = {}) => {
|
||||
const metadata = metadataArg || (await this.retrieveMetadata(contentKey)) || {};
|
||||
const { pr: prMetadata, status } = metadata;
|
||||
|
||||
// Set the status to draft if no corresponding PR is recorded
|
||||
if (!prMetadata && status !== 'draft') {
|
||||
const newMetadata = { ...metadata, status: 'draft' };
|
||||
this.storeMetadata(contentKey, newMetadata);
|
||||
return newMetadata;
|
||||
}
|
||||
|
||||
// If no status is recorded, but there is a PR, check if the PR is
|
||||
// closed or not and update the status accordingly.
|
||||
if (prMetadata) {
|
||||
const { number: prNumber } = prMetadata;
|
||||
const originPRInfo = await this.request(`${this.originRepoURL}/pulls/${prNumber}`);
|
||||
const { state: currentState, merged_at: mergedAt } = originPRInfo;
|
||||
if (currentState === 'closed' && mergedAt) {
|
||||
// The PR has been merged; delete the unpublished entry
|
||||
const [, collectionName, slug] = contentKey.split('/');
|
||||
this.deleteUnpublishedEntry(collectionName, slug);
|
||||
return;
|
||||
} else if (currentState === 'closed' && !mergedAt) {
|
||||
if (status !== 'draft') {
|
||||
const newMetadata = { ...metadata, status: 'draft' };
|
||||
await this.storeMetadata(contentKey, newMetadata);
|
||||
return newMetadata;
|
||||
}
|
||||
} else {
|
||||
if (status !== 'pending_review') {
|
||||
// PR is open and has not been merged
|
||||
const newMetadata = { ...metadata, status: 'pending_review' };
|
||||
await this.storeMetadata(contentKey, newMetadata);
|
||||
return newMetadata;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
};
|
||||
|
||||
async listUnpublishedBranches() {
|
||||
console.log(
|
||||
'%c Checking for Unpublished entries',
|
||||
'line-height: 30px;text-align: center;font-weight: bold',
|
||||
);
|
||||
return this.request(`${this.repoURL}/git/refs/heads/cms`)
|
||||
.then(branches =>
|
||||
filterPromises(branches, branch => {
|
||||
const branchName = branch.ref.substring('/refs/heads/'.length - 1);
|
||||
|
||||
// Get PRs with a `head` of `branchName`. Note that this is a
|
||||
// substring match, so we need to check that the `head.ref` of
|
||||
// at least one of the returned objects matches `branchName`.
|
||||
return this.request(`${this.repoURL}/pulls`, {
|
||||
params: {
|
||||
head: branchName,
|
||||
state: 'open',
|
||||
base: this.branch,
|
||||
},
|
||||
}).then(prs => prs.some(pr => pr.head.ref === branchName));
|
||||
}),
|
||||
)
|
||||
.catch(error => {
|
||||
console.log(
|
||||
'%c No Unpublished entries',
|
||||
'line-height: 30px;text-align: center;font-weight: bold',
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
const onlyBranchesWithOpenPRs = filterPromisesWith(({ ref }) =>
|
||||
this.branchHasPR({ branchName: this.branchNameFromRef(ref), state: 'open' }),
|
||||
);
|
||||
const getUpdatedForkWorkflowBranches = flow([
|
||||
map(async branch => {
|
||||
const contentKey = this.contentKeyFromRef(branch.ref);
|
||||
const metadata = await this.getUpdatedForkWorkflowMetadata(contentKey);
|
||||
// filter out removed entries
|
||||
if (!metadata) {
|
||||
return Promise.reject('Unpublished entry was removed');
|
||||
}
|
||||
return branch;
|
||||
}),
|
||||
onlySuccessfulPromises,
|
||||
]);
|
||||
try {
|
||||
const branches = await this.request(`${this.repoURL}/git/refs/heads/cms`).catch(
|
||||
replace404WithEmptyArray,
|
||||
);
|
||||
const filterFunction = this.useForkWorkflow
|
||||
? getUpdatedForkWorkflowBranches
|
||||
: onlyBranchesWithOpenPRs;
|
||||
return await filterFunction(branches);
|
||||
} catch (err) {
|
||||
console.log(
|
||||
'%c No Unpublished entries',
|
||||
'line-height: 30px;text-align: center;font-weight: bold',
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -289,8 +436,16 @@ export default class API {
|
||||
* concept of entry "status". Useful for things like deploy preview links.
|
||||
*/
|
||||
async getStatuses(sha) {
|
||||
const resp = await this.request(`${this.repoURL}/commits/${sha}/status`);
|
||||
return resp.statuses;
|
||||
const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL;
|
||||
try {
|
||||
const resp = await this.request(`${repoURL}/commits/${sha}/status`);
|
||||
return resp.statuses;
|
||||
} catch (err) {
|
||||
if (err && err.message && err.message === 'Ref not found') {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
composeFileTree(files) {
|
||||
@ -372,84 +527,71 @@ export default class API {
|
||||
});
|
||||
}
|
||||
|
||||
editorialWorkflowGit(fileTree, entry, filesList, options) {
|
||||
const contentKey = entry.slug;
|
||||
async editorialWorkflowGit(fileTree, entry, filesList, options) {
|
||||
const contentKey = this.generateContentKey(options.collectionName, entry.slug);
|
||||
const branchName = this.generateBranchName(contentKey);
|
||||
const unpublished = options.unpublished || false;
|
||||
const branchData = await this.getBranch();
|
||||
if (!unpublished) {
|
||||
// Open new editorial review workflow for this entry - Create new metadata and commit to new branch`
|
||||
let prResponse;
|
||||
|
||||
return this.getBranch()
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then(commitResponse => this.createBranch(branchName, commitResponse.sha))
|
||||
.then(() => this.createPR(options.commitMessage, branchName))
|
||||
.then(pr => {
|
||||
prResponse = pr;
|
||||
return this.user();
|
||||
})
|
||||
.then(user => {
|
||||
return this.storeMetadata(contentKey, {
|
||||
type: 'PR',
|
||||
pr: {
|
||||
number: prResponse.number,
|
||||
head: prResponse.head && prResponse.head.sha,
|
||||
},
|
||||
user: user.name || user.login,
|
||||
status: this.initialWorkflowStatus,
|
||||
branch: branchName,
|
||||
collection: options.collectionName,
|
||||
title: options.parsedData && options.parsedData.title,
|
||||
description: options.parsedData && options.parsedData.description,
|
||||
objects: {
|
||||
entry: {
|
||||
path: entry.path,
|
||||
sha: entry.sha,
|
||||
},
|
||||
files: filesList,
|
||||
},
|
||||
timeStamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
// Open new editorial review workflow for this entry - Create new metadata and commit to new branch
|
||||
const userPromise = this.user();
|
||||
const changeTree = await this.updateTree(branchData.commit.sha, '/', fileTree);
|
||||
const commitResponse = await this.commit(options.commitMessage, changeTree);
|
||||
await this.createBranch(branchName, commitResponse.sha);
|
||||
const pr = this.useForkWorkflow
|
||||
? undefined
|
||||
: await this.createPR(options.commitMessage, branchName);
|
||||
const user = await userPromise;
|
||||
return this.storeMetadata(contentKey, {
|
||||
type: 'PR',
|
||||
pr: pr
|
||||
? {
|
||||
number: pr.number,
|
||||
head: pr.head && pr.head.sha,
|
||||
}
|
||||
: undefined,
|
||||
user: user.name || user.login,
|
||||
status: this.initialWorkflowStatus,
|
||||
branch: branchName,
|
||||
collection: options.collectionName,
|
||||
commitMessage: options.commitMessage,
|
||||
title: options.parsedData && options.parsedData.title,
|
||||
description: options.parsedData && options.parsedData.description,
|
||||
objects: {
|
||||
entry: {
|
||||
path: entry.path,
|
||||
sha: entry.sha,
|
||||
},
|
||||
files: filesList,
|
||||
},
|
||||
timeStamp: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
// Entry is already on editorial review workflow - just update metadata and commit to existing branch
|
||||
let newHead;
|
||||
return this.getBranch(branchName)
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then(commit => {
|
||||
newHead = commit;
|
||||
return this.retrieveMetadata(contentKey);
|
||||
})
|
||||
.then(metadata => {
|
||||
const { title, description } = options.parsedData || {};
|
||||
const metadataFiles = get(metadata.objects, 'files', []);
|
||||
const files = [...metadataFiles, ...filesList];
|
||||
const pr = { ...metadata.pr, head: newHead.sha };
|
||||
const objects = {
|
||||
entry: { path: entry.path, sha: entry.sha },
|
||||
files: uniq(files),
|
||||
};
|
||||
const updatedMetadata = { ...metadata, pr, title, description, objects };
|
||||
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 { 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),
|
||||
};
|
||||
const updatedMetadata = { ...metadata, pr, title, description, objects };
|
||||
|
||||
/**
|
||||
* If an asset store is in use, assets are always accessible, so we
|
||||
* can just finish the persist operation here.
|
||||
*/
|
||||
if (options.hasAssetStore) {
|
||||
return this.storeMetadata(contentKey, updatedMetadata).then(() =>
|
||||
this.patchBranch(branchName, newHead.sha),
|
||||
);
|
||||
}
|
||||
if (options.hasAssetStore) {
|
||||
await this.storeMetadata(contentKey, updatedMetadata);
|
||||
return this.patchBranch(branchName, commit.sha);
|
||||
}
|
||||
|
||||
/**
|
||||
* If no asset store is in use, assets are being stored in the content
|
||||
* repo, which means pull requests opened for editorial workflow
|
||||
* entries must be rebased if assets have been added or removed.
|
||||
*/
|
||||
return this.rebasePullRequest(pr.number, branchName, contentKey, metadata, newHead);
|
||||
});
|
||||
if (pr) {
|
||||
return this.rebasePullRequest(pr.number, branchName, contentKey, metadata, commit);
|
||||
}
|
||||
|
||||
return this.storeMetadata(contentKey, updatedMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
@ -568,14 +710,16 @@ export default class API {
|
||||
* Get a pull request by PR number.
|
||||
*/
|
||||
getPullRequest(prNumber) {
|
||||
return this.request(`${this.repoURL}/pulls/${prNumber} }`);
|
||||
const repoURL = this.useForkWorkflow ? this.repoURL : this.originRepoURL;
|
||||
return this.request(`${repoURL}/pulls/${prNumber} }`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of commits for a given pull request.
|
||||
*/
|
||||
getPullRequestCommits(prNumber) {
|
||||
return this.request(`${this.repoURL}/pulls/${prNumber}/commits`);
|
||||
const repoURL = this.useForkWorkflow ? this.repoURL : this.originRepoURL;
|
||||
return this.request(`${repoURL}/pulls/${prNumber}/commits`);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -596,22 +740,61 @@ export default class API {
|
||||
throw Error('Editorial workflow branch changed unexpectedly.');
|
||||
}
|
||||
|
||||
updateUnpublishedEntryStatus(collection, slug, status) {
|
||||
const contentKey = slug;
|
||||
return this.retrieveMetadata(contentKey)
|
||||
.then(metadata => ({
|
||||
async updateUnpublishedEntryStatus(collectionName, slug, status) {
|
||||
const contentKey = this.generateContentKey(collectionName, slug);
|
||||
const metadata = await this.retrieveMetadata(contentKey);
|
||||
|
||||
if (!this.useForkWorkflow) {
|
||||
return this.storeMetadata(contentKey, {
|
||||
...metadata,
|
||||
status,
|
||||
}))
|
||||
.then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata));
|
||||
});
|
||||
}
|
||||
|
||||
if (status === 'pending_publish') {
|
||||
throw new Error('Fork workflow entries may not be set to the status "pending_publish".');
|
||||
}
|
||||
|
||||
const { pr: prMetadata } = metadata;
|
||||
if (prMetadata) {
|
||||
const { number: prNumber } = prMetadata;
|
||||
const originPRInfo = await this.request(`${this.originRepoURL}/pulls/${prNumber}`);
|
||||
const { state } = originPRInfo;
|
||||
if (state === 'open' && status === 'draft') {
|
||||
await this.closePR(prMetadata);
|
||||
return this.storeMetadata(contentKey, {
|
||||
...metadata,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
if (state === 'closed' && status === 'pending_review') {
|
||||
await this.openPR(prMetadata);
|
||||
return this.storeMetadata(contentKey, {
|
||||
...metadata,
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!prMetadata && status === 'pending_review') {
|
||||
const branchName = this.generateBranchName(contentKey);
|
||||
const commitMessage = metadata.commitMessage || 'Automatically generated by Netlify CMS';
|
||||
const { number, head } = await this.createPR(commitMessage, branchName);
|
||||
return this.storeMetadata(contentKey, {
|
||||
...metadata,
|
||||
pr: { number, head },
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
deleteUnpublishedEntry(collection, slug) {
|
||||
const contentKey = slug;
|
||||
async deleteUnpublishedEntry(collectionName, slug) {
|
||||
const contentKey = this.generateContentKey(collectionName, slug);
|
||||
const branchName = this.generateBranchName(contentKey);
|
||||
return (
|
||||
this.retrieveMetadata(contentKey)
|
||||
.then(metadata => this.closePR(metadata.pr))
|
||||
.then(metadata => (metadata && metadata.pr ? this.closePR(metadata.pr) : Promise.resolve()))
|
||||
.then(() => this.deleteBranch(branchName))
|
||||
// If the PR doesn't exist, then this has already been deleted -
|
||||
// deletion should be idempotent, so we can consider this a
|
||||
@ -620,13 +803,14 @@ export default class API {
|
||||
if (err.message === 'Reference does not exist') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
console.error(err);
|
||||
return Promise.reject(err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
publishUnpublishedEntry(collection, slug) {
|
||||
const contentKey = slug;
|
||||
const contentKey = this.generateContentKey(collection.get('name'), slug);
|
||||
const branchName = this.generateBranchName(contentKey);
|
||||
return this.retrieveMetadata(contentKey)
|
||||
.then(metadata => this.mergePR(metadata.pr, metadata.objects))
|
||||
@ -678,21 +862,36 @@ export default class API {
|
||||
return this.deleteRef('heads', branchName);
|
||||
}
|
||||
|
||||
createPR(title, head, base = this.branch) {
|
||||
async createPR(title, head, base = this.branch) {
|
||||
const body = 'Automatically generated by Netlify CMS';
|
||||
return this.request(`${this.repoURL}/pulls`, {
|
||||
const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL;
|
||||
const headReference = this.useForkWorkflow ? `${(await this.user()).login}:${head}` : head;
|
||||
return this.request(`${repoURL}/pulls`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, body, head, base }),
|
||||
body: JSON.stringify({ title, body, head: headReference, base }),
|
||||
});
|
||||
}
|
||||
|
||||
async openPR(pullRequest) {
|
||||
const { number } = pullRequest;
|
||||
const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL;
|
||||
console.log('%c Re-opening PR', 'line-height: 30px;text-align: center;font-weight: bold');
|
||||
return this.request(`${repoURL}/pulls/${number}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
state: 'open',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
closePR(pullrequest) {
|
||||
const prNumber = pullrequest.number;
|
||||
const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL;
|
||||
console.log('%c Deleting PR', 'line-height: 30px;text-align: center;font-weight: bold');
|
||||
return this.request(`${this.repoURL}/pulls/${prNumber}`, {
|
||||
return this.request(`${repoURL}/pulls/${prNumber}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
state: closed,
|
||||
state: 'closed',
|
||||
}),
|
||||
});
|
||||
}
|
||||
@ -700,8 +899,9 @@ export default class API {
|
||||
mergePR(pullrequest, objects) {
|
||||
const headSha = pullrequest.head;
|
||||
const prNumber = pullrequest.number;
|
||||
const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL;
|
||||
console.log('%c Merging PR', 'line-height: 30px;text-align: center;font-weight: bold');
|
||||
return this.request(`${this.repoURL}/pulls/${prNumber}/merge`, {
|
||||
return this.request(`${repoURL}/pulls/${prNumber}/merge`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
commit_message: 'Automatically generated. Merged on Netlify CMS.',
|
||||
|
@ -9,6 +9,18 @@ const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
|
||||
const ForkApprovalContainer = styled.div`
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
justify-content: space-around;
|
||||
flex-grow: 0.2;
|
||||
`;
|
||||
const ForkButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-around;
|
||||
`;
|
||||
|
||||
export default class GitHubAuthenticationPage extends React.Component {
|
||||
static propTypes = {
|
||||
onLogin: PropTypes.func.isRequired,
|
||||
@ -22,6 +34,35 @@ export default class GitHubAuthenticationPage extends React.Component {
|
||||
|
||||
state = {};
|
||||
|
||||
getPermissionToFork = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.setState({
|
||||
requestingFork: true,
|
||||
approveFork: () => {
|
||||
this.setState({ requestingFork: false });
|
||||
resolve();
|
||||
},
|
||||
refuseFork: () => {
|
||||
this.setState({ requestingFork: false });
|
||||
reject();
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
loginWithForkWorkflow(data) {
|
||||
const { backend } = this.props;
|
||||
|
||||
this.setState({ findingFork: true });
|
||||
return backend
|
||||
.authenticateWithFork({ userData: data, getPermissionToFork: this.getPermissionToFork })
|
||||
.catch(err => {
|
||||
this.setState({ findingFork: false });
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
handleLogin = e => {
|
||||
e.preventDefault();
|
||||
const cfg = {
|
||||
@ -39,23 +80,57 @@ export default class GitHubAuthenticationPage extends React.Component {
|
||||
this.setState({ loginError: err.toString() });
|
||||
return;
|
||||
}
|
||||
if (this.props.config.getIn(['backend', 'fork_workflow'])) {
|
||||
return this.loginWithForkWorkflow(data).then(() => this.props.onLogin(data));
|
||||
}
|
||||
this.props.onLogin(data);
|
||||
});
|
||||
};
|
||||
|
||||
renderLoginButton = () =>
|
||||
this.props.inProgress || this.state.findingFork ? (
|
||||
'Logging in...'
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<LoginButtonIcon type="github" />
|
||||
{' Login with GitHub'}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
getAuthenticationPageRenderArgs() {
|
||||
const { requestingFork } = this.state;
|
||||
|
||||
if (requestingFork) {
|
||||
const { approveFork, refuseFork } = this.state;
|
||||
return {
|
||||
renderPageContent: ({ LoginButton }) => (
|
||||
<ForkApprovalContainer>
|
||||
<p>Forking workflow is enabled: we need to use a fork on your github account.</p>
|
||||
<ForkButtonsContainer>
|
||||
<LoginButton onClick={approveFork}>Fork the repo</LoginButton>
|
||||
<LoginButton onClick={refuseFork}>Don't fork the repo</LoginButton>
|
||||
</ForkButtonsContainer>
|
||||
</ForkApprovalContainer>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
renderButtonContent: this.renderLoginButton,
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { inProgress, config } = this.props;
|
||||
const { loginError, requestingFork, findingFork } = this.state;
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={this.handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={this.state.loginError}
|
||||
loginDisabled={inProgress || findingFork || requestingFork}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.get('logo_url')}
|
||||
renderButtonContent={() => (
|
||||
<React.Fragment>
|
||||
<LoginButtonIcon type="github" /> {inProgress ? 'Logging in...' : 'Login with GitHub'}
|
||||
</React.Fragment>
|
||||
)}
|
||||
{...this.getAuthenticationPageRenderArgs()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import semaphore from 'semaphore';
|
||||
import { stripIndent } from 'common-tags';
|
||||
@ -49,7 +50,17 @@ export default class GitHub {
|
||||
|
||||
this.api = this.options.API || null;
|
||||
|
||||
this.repo = config.getIn(['backend', 'repo'], '');
|
||||
this.forkWorkflowEnabled = config.getIn(['backend', 'fork_workflow'], false);
|
||||
if (this.forkWorkflowEnabled) {
|
||||
if (!this.options.useWorkflow) {
|
||||
throw new Error(
|
||||
'backend.fork_workflow is true but publish_mode is not set to editorial_workflow.',
|
||||
);
|
||||
}
|
||||
this.originRepo = config.getIn(['backend', 'repo'], '');
|
||||
} else {
|
||||
this.repo = config.getIn(['backend', 'repo'], '');
|
||||
}
|
||||
this.branch = config.getIn(['backend', 'branch'], 'master').trim();
|
||||
this.api_root = config.getIn(['backend', 'api_root'], 'https://api.github.com');
|
||||
this.token = '';
|
||||
@ -57,11 +68,89 @@ export default class GitHub {
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
const wrappedAuthenticationPage = props => <AuthenticationPage {...props} backend={this} />;
|
||||
wrappedAuthenticationPage.displayName = 'AuthenticationPage';
|
||||
return wrappedAuthenticationPage;
|
||||
}
|
||||
|
||||
restoreUser(user) {
|
||||
return this.authenticate(user);
|
||||
return this.forkWorkflowEnabled
|
||||
? this.authenticateWithFork({ userData: user, getPermissionToFork: () => true }).then(() =>
|
||||
this.authenticate(user),
|
||||
)
|
||||
: this.authenticate(user);
|
||||
}
|
||||
|
||||
async pollUntilForkExists({ repo, token }) {
|
||||
const pollDelay = 250; // milliseconds
|
||||
var repoExists = false;
|
||||
while (!repoExists) {
|
||||
repoExists = await fetch(`${this.api_root}/repos/${repo}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(err => (err && err.status === 404 ? false : Promise.reject(err)));
|
||||
// wait between polls
|
||||
if (!repoExists) {
|
||||
await new Promise(resolve => setTimeout(resolve, pollDelay));
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async currentUser({ token }) {
|
||||
if (!this._currentUserPromise) {
|
||||
this._currentUserPromise = fetch(`${this.api_root}/user`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}).then(res => res.json());
|
||||
}
|
||||
return this._currentUserPromise;
|
||||
}
|
||||
|
||||
async userIsOriginMaintainer({ username: usernameArg, token }) {
|
||||
const username = usernameArg || (await this.currentUser({ token })).login;
|
||||
this._userIsOriginMaintainerPromises = this._userIsOriginMaintainerPromises || {};
|
||||
if (!this._userIsOriginMaintainerPromises[username]) {
|
||||
this._userIsOriginMaintainerPromises[username] = fetch(
|
||||
`${this.api_root}/repos/${this.originRepo}/collaborators/${username}/permission`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then(res => res.json())
|
||||
.then(({ permission }) => permission === 'admin' || permission === 'write');
|
||||
}
|
||||
return this._userIsOriginMaintainerPromises[username];
|
||||
}
|
||||
|
||||
async authenticateWithFork({ userData, getPermissionToFork }) {
|
||||
if (!this.forkWorkflowEnabled) {
|
||||
throw new Error('Cannot authenticate with fork; forking workflow is turned off.');
|
||||
}
|
||||
const { token } = userData;
|
||||
|
||||
// Origin maintainers should be able to use the CMS normally
|
||||
if (await this.userIsOriginMaintainer({ token })) {
|
||||
this.repo = this.originRepo;
|
||||
this.useForkWorkflow = false;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
await getPermissionToFork();
|
||||
|
||||
const fork = await fetch(`${this.api_root}/repos/${this.originRepo}/forks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}).then(res => res.json());
|
||||
this.useForkWorkflow = true;
|
||||
this.repo = fork.full_name;
|
||||
return this.pollUntilForkExists({ repo: fork.full_name, token });
|
||||
}
|
||||
|
||||
async authenticate(state) {
|
||||
@ -70,8 +159,10 @@ export default class GitHub {
|
||||
token: this.token,
|
||||
branch: this.branch,
|
||||
repo: this.repo,
|
||||
originRepo: this.useForkWorkflow ? this.originRepo : undefined,
|
||||
api_root: this.api_root,
|
||||
squash_merges: this.squash_merges,
|
||||
useForkWorkflow: this.useForkWorkflow,
|
||||
initialWorkflowStatus: this.options.initialWorkflowStatus,
|
||||
});
|
||||
const user = await this.api.user();
|
||||
@ -95,7 +186,7 @@ export default class GitHub {
|
||||
}
|
||||
|
||||
// Authorized user
|
||||
return { ...user, token: state.token };
|
||||
return { ...user, token: state.token, useForkWorkflow: this.useForkWorkflow };
|
||||
}
|
||||
|
||||
logout() {
|
||||
@ -107,22 +198,23 @@ export default class GitHub {
|
||||
return Promise.resolve(this.token);
|
||||
}
|
||||
|
||||
entriesByFolder(collection, extension) {
|
||||
return this.api
|
||||
.listFiles(collection.get('folder'))
|
||||
.then(files => files.filter(file => file.name.endsWith('.' + extension)))
|
||||
.then(this.fetchFiles);
|
||||
async entriesByFolder(collection, extension) {
|
||||
const repoURL = `/repos/${this.useForkWorkflow ? this.originRepo : this.repo}`;
|
||||
const files = await this.api.listFiles(collection.get('folder'));
|
||||
const filteredFiles = files.filter(file => file.name.endsWith('.' + extension));
|
||||
return this.fetchFiles(filteredFiles, { repoURL });
|
||||
}
|
||||
|
||||
entriesByFiles(collection) {
|
||||
const repoURL = `/repos/${this.useForkWorkflow ? this.originRepo : this.repo}`;
|
||||
const files = collection.get('files').map(collectionFile => ({
|
||||
path: collectionFile.get('file'),
|
||||
label: collectionFile.get('label'),
|
||||
}));
|
||||
return this.fetchFiles(files);
|
||||
return this.fetchFiles(files, { repoURL });
|
||||
}
|
||||
|
||||
fetchFiles = files => {
|
||||
fetchFiles = (files, { repoURL = `/repos/${this.repo}` } = {}) => {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const promises = [];
|
||||
files.forEach(file => {
|
||||
@ -130,7 +222,7 @@ export default class GitHub {
|
||||
new Promise(resolve =>
|
||||
sem.take(() =>
|
||||
this.api
|
||||
.readFile(file.path, file.sha)
|
||||
.readFile(file.path, file.sha, { repoURL })
|
||||
.then(data => {
|
||||
resolve({ file, data });
|
||||
sem.leave();
|
||||
@ -151,7 +243,8 @@ export default class GitHub {
|
||||
|
||||
// Fetches a single entry.
|
||||
getEntry(collection, slug, path) {
|
||||
return this.api.readFile(path).then(data => ({
|
||||
const repoURL = `/repos/${this.useForkWorkflow ? this.originRepo : this.repo}`;
|
||||
return this.api.readFile(path, null, { repoURL }).then(data => ({
|
||||
file: { path },
|
||||
data,
|
||||
}));
|
||||
@ -202,13 +295,14 @@ export default class GitHub {
|
||||
.then(branches => {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const promises = [];
|
||||
branches.map(branch => {
|
||||
branches.map(({ ref }) => {
|
||||
promises.push(
|
||||
new Promise(resolve => {
|
||||
const slug = branch.ref.split('refs/heads/cms/').pop();
|
||||
const contentKey = ref.split('refs/heads/cms/').pop();
|
||||
const slug = contentKey.split('/').pop();
|
||||
return sem.take(() =>
|
||||
this.api
|
||||
.readUnpublishedBranchFile(slug)
|
||||
.readUnpublishedBranchFile(contentKey)
|
||||
.then(data => {
|
||||
if (data === null || data === undefined) {
|
||||
resolve(null);
|
||||
@ -239,12 +333,13 @@ export default class GitHub {
|
||||
if (error.message === 'Not Found') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return error;
|
||||
return Promise.reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
unpublishedEntry(collection, slug) {
|
||||
return this.api.readUnpublishedBranchFile(slug).then(data => {
|
||||
const contentKey = this.api.generateContentKey(collection.get('name'), slug);
|
||||
return this.api.readUnpublishedBranchFile(contentKey).then(data => {
|
||||
if (!data) return null;
|
||||
return {
|
||||
slug,
|
||||
@ -263,9 +358,10 @@ export default class GitHub {
|
||||
* 'pending', and 'failure'.
|
||||
*/
|
||||
async getDeployPreview(collection, slug) {
|
||||
const data = await this.api.retrieveMetadata(slug);
|
||||
const contentKey = this.api.generateContentKey(collection.get('name'), slug);
|
||||
const data = await this.api.retrieveMetadata(contentKey);
|
||||
|
||||
if (!data) {
|
||||
if (!data || !data.pr) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -7,6 +7,7 @@ export const AUTH_REQUEST = 'AUTH_REQUEST';
|
||||
export const AUTH_SUCCESS = 'AUTH_SUCCESS';
|
||||
export const AUTH_FAILURE = 'AUTH_FAILURE';
|
||||
export const AUTH_REQUEST_DONE = 'AUTH_REQUEST_DONE';
|
||||
export const USE_FORK_WORKFLOW = 'USE_FORK_WORKFLOW';
|
||||
export const LOGOUT = 'LOGOUT';
|
||||
|
||||
export function authenticating() {
|
||||
@ -36,6 +37,12 @@ export function doneAuthenticating() {
|
||||
};
|
||||
}
|
||||
|
||||
export function useForkWorkflow() {
|
||||
return {
|
||||
type: USE_FORK_WORKFLOW,
|
||||
};
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return {
|
||||
type: LOGOUT,
|
||||
@ -52,6 +59,9 @@ export function authenticateUser() {
|
||||
.currentUser()
|
||||
.then(user => {
|
||||
if (user) {
|
||||
if (user.useForkWorkflow) {
|
||||
dispatch(useForkWorkflow());
|
||||
}
|
||||
dispatch(authenticate(user));
|
||||
} else {
|
||||
dispatch(doneAuthenticating());
|
||||
@ -73,6 +83,9 @@ export function loginUser(credentials) {
|
||||
return backend
|
||||
.authenticate(credentials)
|
||||
.then(user => {
|
||||
if (user.useForkWorkflow) {
|
||||
dispatch(useForkWorkflow());
|
||||
}
|
||||
dispatch(authenticate(user));
|
||||
})
|
||||
.catch(error => {
|
||||
|
@ -250,25 +250,21 @@ export function loadMediaDisplayURL(file) {
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (typeof url === 'string') {
|
||||
if (typeof url === 'string' || typeof displayURL === 'string') {
|
||||
dispatch(mediaDisplayURLRequest(id));
|
||||
return dispatch(mediaDisplayURLSuccess(id, displayURL));
|
||||
}
|
||||
if (typeof displayURL === 'string') {
|
||||
dispatch(mediaDisplayURLRequest(id));
|
||||
return dispatch(mediaDisplayURLSuccess(id, displayURL));
|
||||
dispatch(mediaDisplayURLSuccess(id, displayURL));
|
||||
}
|
||||
try {
|
||||
const backend = currentBackend(state.config);
|
||||
dispatch(mediaDisplayURLRequest(id));
|
||||
const newURL = await backend.getMediaDisplayURL(displayURL);
|
||||
if (newURL) {
|
||||
return dispatch(mediaDisplayURLSuccess(id, newURL));
|
||||
dispatch(mediaDisplayURLSuccess(id, newURL));
|
||||
} else {
|
||||
throw new Error('No display URL was returned!');
|
||||
}
|
||||
} catch (err) {
|
||||
return dispatch(mediaDisplayURLFailure(id, err));
|
||||
dispatch(mediaDisplayURLFailure(id, err));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -61,6 +61,7 @@ class Editor extends React.Component {
|
||||
newEntry: PropTypes.bool.isRequired,
|
||||
displayUrl: PropTypes.string,
|
||||
hasWorkflow: PropTypes.bool,
|
||||
useForkWorkflow: PropTypes.bool,
|
||||
unpublishedEntry: PropTypes.bool,
|
||||
isModification: PropTypes.bool,
|
||||
collectionEntriesLoaded: PropTypes.bool,
|
||||
@ -350,6 +351,7 @@ class Editor extends React.Component {
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
hasWorkflow,
|
||||
useForkWorkflow,
|
||||
unpublishedEntry,
|
||||
newEntry,
|
||||
isModification,
|
||||
@ -397,6 +399,7 @@ class Editor extends React.Component {
|
||||
hasChanged={hasChanged}
|
||||
displayUrl={displayUrl}
|
||||
hasWorkflow={hasWorkflow}
|
||||
useForkWorkflow={useForkWorkflow}
|
||||
hasUnpublishedChanges={unpublishedEntry}
|
||||
isNewEntry={newEntry}
|
||||
isModification={isModification}
|
||||
@ -410,7 +413,7 @@ class Editor extends React.Component {
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collections, entryDraft, auth, config, entries } = state;
|
||||
const { collections, entryDraft, auth, config, entries, globalUI } = state;
|
||||
const slug = ownProps.match.params.slug;
|
||||
const collection = collections.get(ownProps.match.params.name);
|
||||
const collectionName = collection.get('name');
|
||||
@ -422,6 +425,7 @@ function mapStateToProps(state, ownProps) {
|
||||
const hasChanged = entryDraft.get('hasChanged');
|
||||
const displayUrl = config.get('display_url');
|
||||
const hasWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
|
||||
const useForkWorkflow = globalUI.get('useForkWorkflow', false);
|
||||
const isModification = entryDraft.getIn(['entry', 'isModification']);
|
||||
const collectionEntriesLoaded = !!entries.getIn(['pages', collectionName]);
|
||||
const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
|
||||
@ -441,6 +445,7 @@ function mapStateToProps(state, ownProps) {
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
hasWorkflow,
|
||||
useForkWorkflow,
|
||||
isModification,
|
||||
collectionEntriesLoaded,
|
||||
currentStatus,
|
||||
|
@ -166,6 +166,7 @@ class EditorInterface extends Component {
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
hasWorkflow,
|
||||
useForkWorkflow,
|
||||
hasUnpublishedChanges,
|
||||
isNewEntry,
|
||||
isModification,
|
||||
@ -240,6 +241,7 @@ class EditorInterface extends Component {
|
||||
displayUrl={displayUrl}
|
||||
collection={collection}
|
||||
hasWorkflow={hasWorkflow}
|
||||
useForkWorkflow={useForkWorkflow}
|
||||
hasUnpublishedChanges={hasUnpublishedChanges}
|
||||
isNewEntry={isNewEntry}
|
||||
isModification={isModification}
|
||||
@ -293,6 +295,7 @@ EditorInterface.propTypes = {
|
||||
hasChanged: PropTypes.bool,
|
||||
displayUrl: PropTypes.string,
|
||||
hasWorkflow: PropTypes.bool,
|
||||
useForkWorkflow: PropTypes.bool,
|
||||
hasUnpublishedChanges: PropTypes.bool,
|
||||
isNewEntry: PropTypes.bool,
|
||||
isModification: PropTypes.bool,
|
||||
|
@ -218,6 +218,7 @@ class EditorToolbar extends React.Component {
|
||||
displayUrl: PropTypes.string,
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
hasWorkflow: PropTypes.bool,
|
||||
useForkWorkflow: PropTypes.bool,
|
||||
hasUnpublishedChanges: PropTypes.bool,
|
||||
isNewEntry: PropTypes.bool,
|
||||
isModification: PropTypes.bool,
|
||||
@ -379,6 +380,7 @@ class EditorToolbar extends React.Component {
|
||||
onPublishAndNew,
|
||||
currentStatus,
|
||||
isNewEntry,
|
||||
useForkWorkflow,
|
||||
t,
|
||||
} = this.props;
|
||||
if (currentStatus) {
|
||||
@ -406,37 +408,45 @@ class EditorToolbar extends React.Component {
|
||||
onClick={() => onChangeStatus('PENDING_REVIEW')}
|
||||
icon={currentStatus === status.get('PENDING_REVIEW') && 'check'}
|
||||
/>
|
||||
<StatusDropdownItem
|
||||
label={t('editor.editorToolbar.ready')}
|
||||
onClick={() => onChangeStatus('PENDING_PUBLISH')}
|
||||
icon={currentStatus === status.get('PENDING_PUBLISH') && 'check'}
|
||||
/>
|
||||
</ToolbarDropdown>
|
||||
<ToolbarDropdown
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="150px"
|
||||
renderButton={() => (
|
||||
<PublishButton>
|
||||
{isPublishing
|
||||
? t('editor.editorToolbar.publishing')
|
||||
: t('editor.editorToolbar.publish')}
|
||||
</PublishButton>
|
||||
)}
|
||||
>
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.publishNow')}
|
||||
icon="arrow"
|
||||
iconDirection="right"
|
||||
onClick={onPublish}
|
||||
/>
|
||||
{collection.get('create') ? (
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.publishAndCreateNew')}
|
||||
icon="add"
|
||||
onClick={onPublishAndNew}
|
||||
{useForkWorkflow ? (
|
||||
''
|
||||
) : (
|
||||
<StatusDropdownItem
|
||||
label={t('editor.editorToolbar.ready')}
|
||||
onClick={() => onChangeStatus('PENDING_PUBLISH')}
|
||||
icon={currentStatus === status.get('PENDING_PUBLISH') && 'check'}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</ToolbarDropdown>
|
||||
{useForkWorkflow ? (
|
||||
''
|
||||
) : (
|
||||
<ToolbarDropdown
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="150px"
|
||||
renderButton={() => (
|
||||
<PublishButton>
|
||||
{isPublishing
|
||||
? t('editor.editorToolbar.publishing')
|
||||
: t('editor.editorToolbar.publish')}
|
||||
</PublishButton>
|
||||
)}
|
||||
>
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.publishNow')}
|
||||
icon="arrow"
|
||||
iconDirection="right"
|
||||
onClick={onPublish}
|
||||
/>
|
||||
{collection.get('create') ? (
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.publishAndCreateNew')}
|
||||
icon="add"
|
||||
onClick={onPublishAndNew}
|
||||
/>
|
||||
) : null}
|
||||
</ToolbarDropdown>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -55,6 +55,7 @@ class Workflow extends Component {
|
||||
static propTypes = {
|
||||
collections: ImmutablePropTypes.orderedMap,
|
||||
isEditorialWorkflow: PropTypes.bool.isRequired,
|
||||
isForkWorkflow: PropTypes.bool,
|
||||
isFetching: PropTypes.bool,
|
||||
unpublishedEntries: ImmutablePropTypes.map,
|
||||
loadUnpublishedEntries: PropTypes.func.isRequired,
|
||||
@ -74,6 +75,7 @@ class Workflow extends Component {
|
||||
render() {
|
||||
const {
|
||||
isEditorialWorkflow,
|
||||
isForkWorkflow,
|
||||
isFetching,
|
||||
unpublishedEntries,
|
||||
updateUnpublishedEntryStatus,
|
||||
@ -125,6 +127,7 @@ class Workflow extends Component {
|
||||
handleChangeStatus={updateUnpublishedEntryStatus}
|
||||
handlePublish={publishUnpublishedEntry}
|
||||
handleDelete={deleteUnpublishedEntry}
|
||||
isForkWorkflow={isForkWorkflow}
|
||||
/>
|
||||
</WorkflowContainer>
|
||||
);
|
||||
@ -132,9 +135,10 @@ class Workflow extends Component {
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const { collections } = state;
|
||||
const isEditorialWorkflow = state.config.get('publish_mode') === EDITORIAL_WORKFLOW;
|
||||
const returnObj = { collections, isEditorialWorkflow };
|
||||
const { collections, config, globalUI } = state;
|
||||
const isEditorialWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
|
||||
const isForkWorkflow = globalUI.get('useForkWorkflow', false);
|
||||
const returnObj = { collections, isEditorialWorkflow, isForkWorkflow };
|
||||
|
||||
if (isEditorialWorkflow) {
|
||||
returnObj.isFetching = state.editorialWorkflow.getIn(['pages', 'isFetching'], false);
|
||||
|
@ -17,6 +17,12 @@ const WorkflowListContainer = styled.div`
|
||||
grid-template-columns: 33.3% 33.3% 33.3%;
|
||||
`;
|
||||
|
||||
const WorkflowListContainerForkWorkflow = styled.div`
|
||||
min-height: 60%;
|
||||
display: grid;
|
||||
grid-template-columns: 50% 50% 0%;
|
||||
`;
|
||||
|
||||
const styles = {
|
||||
columnPosition: idx =>
|
||||
(idx === 0 &&
|
||||
@ -58,6 +64,16 @@ const styles = {
|
||||
columnHovered: css`
|
||||
border-color: ${colors.active};
|
||||
`,
|
||||
hiddenColumn: css`
|
||||
display: none;
|
||||
`,
|
||||
hiddenRightBorder: css`
|
||||
&:not(:first-child):not(:last-child) {
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const ColumnHeader = styled.h2`
|
||||
@ -118,6 +134,7 @@ class WorkflowList extends React.Component {
|
||||
handlePublish: PropTypes.func.isRequired,
|
||||
handleDelete: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
isForkWorkflow: PropTypes.bool,
|
||||
};
|
||||
|
||||
handleChangeStatus = (newStatus, dragProps) => {
|
||||
@ -145,6 +162,7 @@ class WorkflowList extends React.Component {
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderColumns = (entries, column) => {
|
||||
const { isForkWorkflow } = this.props;
|
||||
if (!entries) return null;
|
||||
|
||||
if (!column) {
|
||||
@ -162,6 +180,8 @@ class WorkflowList extends React.Component {
|
||||
styles.column,
|
||||
styles.columnPosition(idx),
|
||||
isHovered && styles.columnHovered,
|
||||
isForkWorkflow && currColumn === 'pending_publish' && styles.hiddenColumn,
|
||||
isForkWorkflow && currColumn === 'pending_review' && styles.hiddenRightBorder,
|
||||
]}
|
||||
>
|
||||
<ColumnHeader name={currColumn}>
|
||||
@ -228,7 +248,10 @@ class WorkflowList extends React.Component {
|
||||
|
||||
render() {
|
||||
const columns = this.renderColumns(this.props.entries);
|
||||
return <WorkflowListContainer>{columns}</WorkflowListContainer>;
|
||||
const ListContainer = this.props.isForkWorkflow
|
||||
? WorkflowListContainerForkWorkflow
|
||||
: WorkflowListContainer;
|
||||
return <ListContainer>{columns}</ListContainer>;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,13 +1,16 @@
|
||||
import { Map } from 'immutable';
|
||||
import { USE_FORK_WORKFLOW } from 'Actions/auth';
|
||||
/*
|
||||
* Reducer for some global UI state that we want to share between components
|
||||
* */
|
||||
const globalUI = (state = Map({ isFetching: false }), action) => {
|
||||
const globalUI = (state = Map({ isFetching: false, useForkWorkflow: false }), action) => {
|
||||
// Generic, global loading indicator
|
||||
if (action.type.indexOf('REQUEST') > -1) {
|
||||
return state.set('isFetching', true);
|
||||
} else if (action.type.indexOf('SUCCESS') > -1 || action.type.indexOf('FAILURE') > -1) {
|
||||
return state.set('isFetching', false);
|
||||
} else if (action.type === USE_FORK_WORKFLOW) {
|
||||
return state.set('useForkWorkflow', true);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
@ -3,7 +3,13 @@ import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from './Cursor';
|
||||
import EditorialWorkflowError, { EDITORIAL_WORKFLOW_ERROR } from './EditorialWorkflowError';
|
||||
import localForage from './localForage';
|
||||
import { resolvePath, basename, fileExtensionWithSeparator, fileExtension } from './path';
|
||||
import { filterPromises, resolvePromiseProperties, then } from './promise';
|
||||
import {
|
||||
filterPromises,
|
||||
filterPromisesWith,
|
||||
onlySuccessfulPromises,
|
||||
resolvePromiseProperties,
|
||||
then,
|
||||
} from './promise';
|
||||
import unsentRequest from './unsentRequest';
|
||||
import { filterByPropExtension, parseResponse, responseParser } from './backendUtil';
|
||||
import loadScript from './loadScript';
|
||||
@ -21,6 +27,8 @@ export const NetlifyCmsLibUtil = {
|
||||
fileExtensionWithSeparator,
|
||||
fileExtension,
|
||||
filterPromises,
|
||||
filterPromisesWith,
|
||||
onlySuccessfulPromises,
|
||||
resolvePromiseProperties,
|
||||
then,
|
||||
unsentRequest,
|
||||
@ -42,6 +50,8 @@ export {
|
||||
fileExtensionWithSeparator,
|
||||
fileExtension,
|
||||
filterPromises,
|
||||
filterPromisesWith,
|
||||
onlySuccessfulPromises,
|
||||
resolvePromiseProperties,
|
||||
then,
|
||||
unsentRequest,
|
||||
|
@ -1,7 +1,15 @@
|
||||
import constant from 'lodash/constant';
|
||||
import filter from 'lodash/fp/filter';
|
||||
import map from 'lodash/fp/map';
|
||||
import flow from 'lodash/flow';
|
||||
import zipObject from 'lodash/zipObject';
|
||||
|
||||
export const filterPromises = (arr, filter) =>
|
||||
Promise.all(arr.map(entry => filter(entry))).then(bits => arr.filter(() => bits.shift()));
|
||||
Promise.all(arr.map(entry => Promise.resolve(entry).then(filter))).then(bits =>
|
||||
arr.filter(() => bits.shift()),
|
||||
);
|
||||
|
||||
export const filterPromisesWith = filter => arr => filterPromises(arr, filter);
|
||||
|
||||
export const resolvePromiseProperties = obj => {
|
||||
// Get the keys which represent promises
|
||||
@ -18,3 +26,10 @@ export const resolvePromiseProperties = obj => {
|
||||
};
|
||||
|
||||
export const then = fn => p => Promise.resolve(p).then(fn);
|
||||
|
||||
const filterPromiseSymbol = Symbol('filterPromiseSymbol');
|
||||
export const onlySuccessfulPromises = flow([
|
||||
then(map(p => p.catch(constant(filterPromiseSymbol)))),
|
||||
then(Promise.all.bind(Promise)),
|
||||
then(filter(maybeValue => maybeValue !== filterPromiseSymbol)),
|
||||
]);
|
||||
|
@ -72,7 +72,7 @@ const AuthenticationPage = ({
|
||||
<StyledAuthenticationPage>
|
||||
{renderPageLogo(logoUrl)}
|
||||
{loginErrorMessage ? <p>{loginErrorMessage}</p> : null}
|
||||
{!renderPageContent ? null : renderPageContent()}
|
||||
{!renderPageContent ? null : renderPageContent({ LoginButton })}
|
||||
{!renderButtonContent ? null : (
|
||||
<LoginButton disabled={loginDisabled} onClick={onLogin}>
|
||||
{renderButtonContent()}
|
||||
|
Reference in New Issue
Block a user