From 34e1f0910544d38b5142b0e45c178441faecb7a3 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Sat, 24 Aug 2019 10:54:59 -0700 Subject: [PATCH] Open Authoring bugfixes and pagination improvements (#2523) * Fix handling of displayURLs which are strings * Add fromFetchArguments to unsentRequest * Add parseLinkHeader to backendUtil * Handle paginated endpoints in GitHub API * Rename fork workflow to Open Authoring across the whole repo * Fixes for bugs in GitHub API introduced by Open Authoring changes * Fix getDeployPreview * Fix incorrect auth header formatting GitHub implementation cf. https://github.com/netlify/netlify-cms/pull/2456#discussion_r309633387 * Remove unused and broken method from GitHub API cf. https://github.com/netlify/netlify-cms/pull/2456#discussion_r308687145 * Fix editorialWorkflowGit method in GitHub API * Request published entry content from origin repo * Better error when deleting a published post in Open Authoring * Rename to Open Authoring in fork request message Also adds a note to the fork request message that an existing fork of the same repo will be used automatically. * fix linting --- .../netlify-cms-backend-github/src/API.js | 157 ++++++++++-------- .../src/AuthenticationPage.js | 11 +- .../src/implementation.js | 41 ++--- .../netlify-cms-backend-gitlab/src/API.js | 27 ++- packages/netlify-cms-core/src/actions/auth.js | 14 +- .../src/actions/mediaLibrary.js | 3 +- .../src/components/Editor/Editor.js | 10 +- .../src/components/Editor/EditorInterface.js | 6 +- .../src/components/Editor/EditorToolbar.js | 8 +- .../src/components/Workflow/Workflow.js | 10 +- .../src/components/Workflow/WorkflowList.js | 14 +- .../netlify-cms-core/src/reducers/globalUI.js | 8 +- .../netlify-cms-lib-util/src/backendUtil.js | 37 ++++- packages/netlify-cms-lib-util/src/index.js | 11 +- .../netlify-cms-lib-util/src/unsentRequest.js | 11 +- website/content/docs/open-authoring.md | 6 +- 16 files changed, 223 insertions(+), 151 deletions(-) diff --git a/packages/netlify-cms-backend-github/src/API.js b/packages/netlify-cms-backend-github/src/API.js index 50b02216..2353161e 100644 --- a/packages/netlify-cms-backend-github/src/API.js +++ b/packages/netlify-cms-backend-github/src/API.js @@ -3,6 +3,7 @@ import semaphore from 'semaphore'; import { find, flow, get, hasIn, initial, last, partial, result, uniq } from 'lodash'; import { map } from 'lodash/fp'; import { + getPaginatedRequestIterator, APIError, EditorialWorkflowError, filterPromisesWith, @@ -11,7 +12,7 @@ import { resolvePromiseProperties, } from 'netlify-cms-lib-util'; -const CMS_BRANCH_PREFIX = 'cms/'; +const CMS_BRANCH_PREFIX = 'cms'; const replace404WithEmptyArray = err => (err && err.status === 404 ? [] : Promise.reject(err)); @@ -21,7 +22,7 @@ export default class API { this.token = config.token || false; this.branch = config.branch || 'master'; this.originRepo = config.originRepo; - this.useForkWorkflow = config.useForkWorkflow; + this.useOpenAuthoring = config.useOpenAuthoring; this.repo = config.repo || ''; this.repoURL = `/repos/${this.repo}`; this.originRepoURL = this.originRepo && `/repos/${this.originRepo}`; @@ -83,6 +84,20 @@ export default class API { return this.api_root + path; } + parseResponse(response) { + const contentType = response.headers.get('Content-Type'); + if (contentType && contentType.match(/json/)) { + return this.parseJsonResponse(response); + } + const textPromise = response.text().then(text => { + if (!response.ok) { + return Promise.reject(text); + } + return text; + }); + return textPromise; + } + request(path, options = {}) { const headers = this.requestHeaders(options.headers || {}); const url = this.urlFor(path, options); @@ -90,23 +105,26 @@ export default class API { return fetch(url, { ...options, headers }) .then(response => { responseStatus = response.status; - const contentType = response.headers.get('Content-Type'); - if (contentType && contentType.match(/json/)) { - return this.parseJsonResponse(response); - } - const text = response.text(); - if (!response.ok) { - return Promise.reject(text); - } - return text; + return this.parseResponse(response); }) .catch(error => { throw new APIError(error.message, responseStatus, 'GitHub'); }); } + async requestAllPages(url, options = {}) { + const processedURL = this.urlFor(url, options); + const pagesIterator = getPaginatedRequestIterator(processedURL, options); + const pagesToParse = []; + for await (const page of pagesIterator) { + pagesToParse.push(this.parseResponse(page)); + } + const pages = await Promise.all(pagesToParse); + return [].concat(...pages); + } + generateContentKey(collectionName, slug) { - if (!this.useForkWorkflow) { + if (!this.useOpenAuthoring) { // this doesn't use the collection, but we need to leave it that way for backwards // compatibility return slug; @@ -115,22 +133,16 @@ export default class API { 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}`; + return `${CMS_BRANCH_PREFIX}/${contentKey}`; } branchNameFromRef(ref) { - return ref.substring('refs/heads/'.length - 1); + return ref.substring('refs/heads/'.length); } contentKeyFromRef(ref) { - return ref.substring(`refs/heads/${CMS_BRANCH_PREFIX}/`.length - 1); + return ref.substring(`refs/heads/${CMS_BRANCH_PREFIX}/`.length); } checkMetadataRef() { @@ -212,28 +224,34 @@ export default class API { cache: 'no-store', }; - if (!this.useForkWorkflow) { + if (!this.useOpenAuthoring) { 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, - ), - ); + .catch(err => { + if (err.message === 'Not Found') { + console.log( + '%c %s does not have metadata', + 'line-height: 30px;text-align: center;font-weight: bold', + key, + ); + } + throw err; + }); } const [user, repo] = key.split('/'); return this.request(`/repos/${user}/${repo}/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, - ), - ); + .catch(err => { + if (err.message === 'Not Found') { + console.log( + '%c %s does not have metadata', + 'line-height: 30px;text-align: center;font-weight: bold', + key, + ); + } + throw err; + }); }); } @@ -251,22 +269,22 @@ export default class API { .split('/') .slice(0, -1) .join('/'); - return this.listFiles(dir) + return this.listFiles(dir, { repoURL, branch }) .then(files => files.find(file => file.path === path)) - .then(file => this.getBlob(file.sha)); + .then(file => this.getBlob(file.sha, { repoURL })); } throw error; }); } } - getBlob(sha) { + getBlob(sha, { repoURL = this.repoURL } = {}) { return localForage.getItem(`gh.${sha}`).then(cached => { if (cached) { return cached; } - return this.request(`${this.repoURL}/git/blobs/${sha}`, { + return this.request(`${repoURL}/git/blobs/${sha}`, { headers: { Accept: 'application/vnd.github.VERSION.raw' }, }).then(result => { localForage.setItem(`gh.${sha}`, result); @@ -275,9 +293,9 @@ export default class API { }); } - listFiles(path) { - return this.request(`${this.repoURL}/contents/${path.replace(/\/$/, '')}`, { - params: { ref: this.branch }, + listFiles(path, { repoURL = this.repoURL, branch = this.branch } = {}) { + return this.request(`${repoURL}/contents/${path.replace(/\/$/, '')}`, { + params: { ref: branch }, }) .then(files => { if (!Array.isArray(files)) { @@ -292,7 +310,7 @@ export default class API { const metaDataPromise = this.retrieveMetadata(contentKey).then(data => data.objects.entry.path ? data : Promise.reject(null), ); - const repoURL = this.useForkWorkflow + const repoURL = this.useOpenAuthoring ? `/repos/${contentKey .split('/') .slice(0, 2) @@ -317,7 +335,7 @@ export default class API { isUnpublishedEntryModification(path, branch) { return this.readFile(path, null, { branch, - repoURL: this.useForkWorkflow ? this.originRepoURL : this.repoURL, + repoURL: this.useOpenAuthoring ? this.originRepoURL : this.repoURL, }) .then(() => true) .catch(err => { @@ -338,8 +356,7 @@ export default class API { // 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`, { + return this.requestAllPages(`${repoURL}/pulls`, { params: { head: usernameOfFork ? `${usernameOfFork}:${branchName}` : branchName, ...(state ? { state } : {}), @@ -350,10 +367,10 @@ export default class API { branchHasPR = async ({ branchName, ...rest }) => { const prs = await this.getPRsForBranchName({ branchName, ...rest }); - return prs.some(pr => this.branchNameFromRef(pr.head.ref) === branchName); + return prs.some(pr => pr.head.ref === branchName); }; - getUpdatedForkWorkflowMetadata = async (contentKey, { metadata: metadataArg } = {}) => { + getUpdatedOpenAuthoringMetadata = async (contentKey, { metadata: metadataArg } = {}) => { const metadata = metadataArg || (await this.retrieveMetadata(contentKey)) || {}; const { pr: prMetadata, status } = metadata; @@ -402,10 +419,10 @@ export default class API { const onlyBranchesWithOpenPRs = filterPromisesWith(({ ref }) => this.branchHasPR({ branchName: this.branchNameFromRef(ref), state: 'open' }), ); - const getUpdatedForkWorkflowBranches = flow([ + const getUpdatedOpenAuthoringBranches = flow([ map(async branch => { const contentKey = this.contentKeyFromRef(branch.ref); - const metadata = await this.getUpdatedForkWorkflowMetadata(contentKey); + const metadata = await this.getUpdatedOpenAuthoringMetadata(contentKey); // filter out removed entries if (!metadata) { return Promise.reject('Unpublished entry was removed'); @@ -418,8 +435,8 @@ export default class API { const branches = await this.request(`${this.repoURL}/git/refs/heads/cms`).catch( replace404WithEmptyArray, ); - const filterFunction = this.useForkWorkflow - ? getUpdatedForkWorkflowBranches + const filterFunction = this.useOpenAuthoring + ? getUpdatedOpenAuthoringBranches : onlyBranchesWithOpenPRs; return await filterFunction(branches); } catch (err) { @@ -436,7 +453,7 @@ export default class API { * concept of entry "status". Useful for things like deploy preview links. */ async getStatuses(sha) { - const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL; + const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL; try { const resp = await this.request(`${repoURL}/commits/${sha}/status`); return resp.statuses; @@ -501,6 +518,9 @@ export default class API { } deleteFile(path, message, options = {}) { + if (this.useOpenAuthoring) { + return Promise.reject('Cannot delete published entries as an Open Authoring user!'); + } const branch = options.branch || this.branch; const pathArray = path.split('/'); const filename = last(pathArray); @@ -531,14 +551,14 @@ export default class API { 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 const userPromise = this.user(); + const branchData = await this.getBranch(); 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 + const pr = this.useOpenAuthoring ? undefined : await this.createPR(options.commitMessage, branchName); const user = await userPromise; @@ -568,6 +588,7 @@ export default class API { }); } else { // Entry is already on editorial review workflow - just update metadata and commit to existing branch + 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); @@ -710,7 +731,7 @@ export default class API { * Get a pull request by PR number. */ getPullRequest(prNumber) { - const repoURL = this.useForkWorkflow ? this.repoURL : this.originRepoURL; + const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL; return this.request(`${repoURL}/pulls/${prNumber} }`); } @@ -718,7 +739,7 @@ export default class API { * Get the list of commits for a given pull request. */ getPullRequestCommits(prNumber) { - const repoURL = this.useForkWorkflow ? this.repoURL : this.originRepoURL; + const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL; return this.request(`${repoURL}/pulls/${prNumber}/commits`); } @@ -744,7 +765,7 @@ export default class API { const contentKey = this.generateContentKey(collectionName, slug); const metadata = await this.retrieveMetadata(contentKey); - if (!this.useForkWorkflow) { + if (!this.useOpenAuthoring) { return this.storeMetadata(contentKey, { ...metadata, status, @@ -752,7 +773,7 @@ export default class API { } if (status === 'pending_publish') { - throw new Error('Fork workflow entries may not be set to the status "pending_publish".'); + throw new Error('Open Authoring entries may not be set to the status "pending_publish".'); } const { pr: prMetadata } = metadata; @@ -809,8 +830,8 @@ export default class API { ); } - publishUnpublishedEntry(collection, slug) { - const contentKey = this.generateContentKey(collection.get('name'), slug); + 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)) @@ -847,7 +868,7 @@ export default class API { } assertCmsBranch(branchName) { - return branchName.startsWith(CMS_BRANCH_PREFIX); + return branchName.startsWith(`${CMS_BRANCH_PREFIX}/`); } patchBranch(branchName, sha, opts = {}) { @@ -864,8 +885,8 @@ export default class API { async createPR(title, head, base = this.branch) { const body = 'Automatically generated by Netlify CMS'; - const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL; - const headReference = this.useForkWorkflow ? `${(await this.user()).login}:${head}` : head; + const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL; + const headReference = this.useOpenAuthoring ? `${(await this.user()).login}:${head}` : head; return this.request(`${repoURL}/pulls`, { method: 'POST', body: JSON.stringify({ title, body, head: headReference, base }), @@ -874,7 +895,7 @@ export default class API { async openPR(pullRequest) { const { number } = pullRequest; - const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL; + const repoURL = this.useOpenAuthoring ? 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', @@ -886,7 +907,7 @@ export default class API { closePR(pullrequest) { const prNumber = pullrequest.number; - const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL; + const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL; console.log('%c Deleting PR', 'line-height: 30px;text-align: center;font-weight: bold'); return this.request(`${repoURL}/pulls/${prNumber}`, { method: 'PATCH', @@ -899,7 +920,7 @@ export default class API { mergePR(pullrequest, objects) { const headSha = pullrequest.head; const prNumber = pullrequest.number; - const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL; + const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL; console.log('%c Merging PR', 'line-height: 30px;text-align: center;font-weight: bold'); return this.request(`${repoURL}/pulls/${prNumber}/merge`, { method: 'PUT', diff --git a/packages/netlify-cms-backend-github/src/AuthenticationPage.js b/packages/netlify-cms-backend-github/src/AuthenticationPage.js index 2a3d5177..8e84e189 100644 --- a/packages/netlify-cms-backend-github/src/AuthenticationPage.js +++ b/packages/netlify-cms-backend-github/src/AuthenticationPage.js @@ -50,7 +50,7 @@ export default class GitHubAuthenticationPage extends React.Component { }); }; - loginWithForkWorkflow(data) { + loginWithOpenAuthoring(data) { const { backend } = this.props; this.setState({ findingFork: true }); @@ -80,8 +80,8 @@ 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)); + if (this.props.config.getIn(['backend', 'open_authoring'])) { + return this.loginWithOpenAuthoring(data).then(() => this.props.onLogin(data)); } this.props.onLogin(data); }); @@ -105,7 +105,10 @@ export default class GitHubAuthenticationPage extends React.Component { return { renderPageContent: ({ LoginButton }) => ( -

Forking workflow is enabled: we need to use a fork on your github account.

+

+ Open Authoring is enabled: we need to use a fork on your github account. (If a fork + already exists, we'll use that.) +

Fork the repo Don't fork the repo diff --git a/packages/netlify-cms-backend-github/src/implementation.js b/packages/netlify-cms-backend-github/src/implementation.js index 5784d17a..446dea28 100644 --- a/packages/netlify-cms-backend-github/src/implementation.js +++ b/packages/netlify-cms-backend-github/src/implementation.js @@ -50,11 +50,11 @@ export default class GitHub { this.api = this.options.API || null; - this.forkWorkflowEnabled = config.getIn(['backend', 'fork_workflow'], false); - if (this.forkWorkflowEnabled) { + this.openAuthoringEnabled = config.getIn(['backend', 'open_authoring'], false); + if (this.openAuthoringEnabled) { if (!this.options.useWorkflow) { throw new Error( - 'backend.fork_workflow is true but publish_mode is not set to editorial_workflow.', + 'backend.open_authoring is true but publish_mode is not set to editorial_workflow.', ); } this.originRepo = config.getIn(['backend', 'repo'], ''); @@ -74,7 +74,7 @@ export default class GitHub { } restoreUser(user) { - return this.forkWorkflowEnabled + return this.openAuthoringEnabled ? this.authenticateWithFork({ userData: user, getPermissionToFork: () => true }).then(() => this.authenticate(user), ) @@ -86,7 +86,7 @@ export default class GitHub { var repoExists = false; while (!repoExists) { repoExists = await fetch(`${this.api_root}/repos/${repo}`, { - headers: { Authorization: `Bearer ${token}` }, + headers: { Authorization: `token ${token}` }, }) .then(() => true) .catch(err => (err && err.status === 404 ? false : Promise.reject(err))); @@ -102,7 +102,7 @@ export default class GitHub { if (!this._currentUserPromise) { this._currentUserPromise = fetch(`${this.api_root}/user`, { headers: { - Authorization: `Bearer ${token}`, + Authorization: `token ${token}`, }, }).then(res => res.json()); } @@ -117,7 +117,7 @@ export default class GitHub { `${this.api_root}/repos/${this.originRepo}/collaborators/${username}/permission`, { headers: { - Authorization: `Bearer ${token}`, + Authorization: `token ${token}`, }, }, ) @@ -128,15 +128,15 @@ export default class GitHub { } async authenticateWithFork({ userData, getPermissionToFork }) { - if (!this.forkWorkflowEnabled) { - throw new Error('Cannot authenticate with fork; forking workflow is turned off.'); + if (!this.openAuthoringEnabled) { + throw new Error('Cannot authenticate with fork; Open Authoring 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; + this.useOpenAuthoring = false; return Promise.resolve(); } @@ -145,10 +145,10 @@ export default class GitHub { const fork = await fetch(`${this.api_root}/repos/${this.originRepo}/forks`, { method: 'POST', headers: { - Authorization: `Bearer ${token}`, + Authorization: `token ${token}`, }, }).then(res => res.json()); - this.useForkWorkflow = true; + this.useOpenAuthoring = true; this.repo = fork.full_name; return this.pollUntilForkExists({ repo: fork.full_name, token }); } @@ -159,10 +159,10 @@ export default class GitHub { token: this.token, branch: this.branch, repo: this.repo, - originRepo: this.useForkWorkflow ? this.originRepo : undefined, + originRepo: this.useOpenAuthoring ? this.originRepo : undefined, api_root: this.api_root, squash_merges: this.squash_merges, - useForkWorkflow: this.useForkWorkflow, + useOpenAuthoring: this.useOpenAuthoring, initialWorkflowStatus: this.options.initialWorkflowStatus, }); const user = await this.api.user(); @@ -186,7 +186,7 @@ export default class GitHub { } // Authorized user - return { ...user, token: state.token, useForkWorkflow: this.useForkWorkflow }; + return { ...user, token: state.token, useOpenAuthoring: this.useOpenAuthoring }; } logout() { @@ -199,14 +199,14 @@ export default class GitHub { } async entriesByFolder(collection, extension) { - const repoURL = `/repos/${this.useForkWorkflow ? this.originRepo : this.repo}`; - const files = await this.api.listFiles(collection.get('folder')); + const repoURL = `/repos/${this.useOpenAuthoring ? this.originRepo : this.repo}`; + const files = await this.api.listFiles(collection.get('folder'), { repoURL }); 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 repoURL = `/repos/${this.useOpenAuthoring ? this.originRepo : this.repo}`; const files = collection.get('files').map(collectionFile => ({ path: collectionFile.get('file'), label: collectionFile.get('label'), @@ -243,7 +243,7 @@ export default class GitHub { // Fetches a single entry. getEntry(collection, slug, path) { - const repoURL = `/repos/${this.useForkWorkflow ? this.originRepo : this.repo}`; + const repoURL = `/repos/${this.useOpenAuthoring ? this.originRepo : this.repo}`; return this.api.readFile(path, null, { repoURL }).then(data => ({ file: { path }, data, @@ -365,7 +365,8 @@ export default class GitHub { return null; } - const statuses = await this.api.getStatuses(data.pr.head); + const headSHA = typeof data.pr.head === 'string' ? data.pr.head : data.pr.head.sha; + const statuses = await this.api.getStatuses(headSHA); const deployStatus = getPreviewStatus(statuses, this.config); if (deployStatus) { diff --git a/packages/netlify-cms-backend-gitlab/src/API.js b/packages/netlify-cms-backend-gitlab/src/API.js index edc9775b..dc80f3fc 100644 --- a/packages/netlify-cms-backend-gitlab/src/API.js +++ b/packages/netlify-cms-backend-gitlab/src/API.js @@ -1,6 +1,13 @@ -import { localForage, unsentRequest, then, APIError, Cursor } from 'netlify-cms-lib-util'; +import { + localForage, + parseLinkHeader, + unsentRequest, + then, + APIError, + Cursor, +} from 'netlify-cms-lib-util'; import { Base64 } from 'js-base64'; -import { fromJS, List, Map } from 'immutable'; +import { fromJS, Map } from 'immutable'; import { flow, partial, result } from 'lodash'; export default class API { @@ -114,20 +121,8 @@ export default class API { const pageCount = parseInt(headers.get('X-Total-Pages'), 10) - 1; const pageSize = parseInt(headers.get('X-Per-Page'), 10); const count = parseInt(headers.get('X-Total'), 10); - const linksRaw = headers.get('Link'); - const links = List(linksRaw.split(',')) - .map(str => str.trim().split(';')) - .map(([linkStr, keyStr]) => [ - keyStr.match(/rel="(.*?)"/)[1], - unsentRequest.fromURL( - linkStr - .trim() - .match(/<(.*?)>/)[1] - .replace(/\+/g, '%20'), - ), - ]) - .update(list => Map(list)); - const actions = links + const links = parseLinkHeader(headers.get('Link')); + const actions = Map(links) .keySeq() .flatMap(key => (key === 'prev' && index > 0) || diff --git a/packages/netlify-cms-core/src/actions/auth.js b/packages/netlify-cms-core/src/actions/auth.js index 73d0f775..410c7694 100644 --- a/packages/netlify-cms-core/src/actions/auth.js +++ b/packages/netlify-cms-core/src/actions/auth.js @@ -7,7 +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 USE_OPEN_AUTHORING = 'USE_OPEN_AUTHORING'; export const LOGOUT = 'LOGOUT'; export function authenticating() { @@ -37,9 +37,9 @@ export function doneAuthenticating() { }; } -export function useForkWorkflow() { +export function useOpenAuthoring() { return { - type: USE_FORK_WORKFLOW, + type: USE_OPEN_AUTHORING, }; } @@ -59,8 +59,8 @@ export function authenticateUser() { .currentUser() .then(user => { if (user) { - if (user.useForkWorkflow) { - dispatch(useForkWorkflow()); + if (user.useOpenAuthoring) { + dispatch(useOpenAuthoring()); } dispatch(authenticate(user)); } else { @@ -83,8 +83,8 @@ export function loginUser(credentials) { return backend .authenticate(credentials) .then(user => { - if (user.useForkWorkflow) { - dispatch(useForkWorkflow()); + if (user.useOpenAuthoring) { + dispatch(useOpenAuthoring()); } dispatch(authenticate(user)); }) diff --git a/packages/netlify-cms-core/src/actions/mediaLibrary.js b/packages/netlify-cms-core/src/actions/mediaLibrary.js index 6f9f7296..b804a0cd 100644 --- a/packages/netlify-cms-core/src/actions/mediaLibrary.js +++ b/packages/netlify-cms-core/src/actions/mediaLibrary.js @@ -250,9 +250,10 @@ export function loadMediaDisplayURL(file) { ) { return Promise.resolve(); } - if (typeof url === 'string' || typeof displayURL === 'string') { + if (typeof displayURL === 'string') { dispatch(mediaDisplayURLRequest(id)); dispatch(mediaDisplayURLSuccess(id, displayURL)); + return; } try { const backend = currentBackend(state.config); diff --git a/packages/netlify-cms-core/src/components/Editor/Editor.js b/packages/netlify-cms-core/src/components/Editor/Editor.js index 69fb80df..5afccbd8 100644 --- a/packages/netlify-cms-core/src/components/Editor/Editor.js +++ b/packages/netlify-cms-core/src/components/Editor/Editor.js @@ -61,7 +61,7 @@ class Editor extends React.Component { newEntry: PropTypes.bool.isRequired, displayUrl: PropTypes.string, hasWorkflow: PropTypes.bool, - useForkWorkflow: PropTypes.bool, + useOpenAuthoring: PropTypes.bool, unpublishedEntry: PropTypes.bool, isModification: PropTypes.bool, collectionEntriesLoaded: PropTypes.bool, @@ -351,7 +351,7 @@ class Editor extends React.Component { hasChanged, displayUrl, hasWorkflow, - useForkWorkflow, + useOpenAuthoring, unpublishedEntry, newEntry, isModification, @@ -399,7 +399,7 @@ class Editor extends React.Component { hasChanged={hasChanged} displayUrl={displayUrl} hasWorkflow={hasWorkflow} - useForkWorkflow={useForkWorkflow} + useOpenAuthoring={useOpenAuthoring} hasUnpublishedChanges={unpublishedEntry} isNewEntry={newEntry} isModification={isModification} @@ -425,7 +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 useOpenAuthoring = globalUI.get('useOpenAuthoring', false); const isModification = entryDraft.getIn(['entry', 'isModification']); const collectionEntriesLoaded = !!entries.getIn(['pages', collectionName]); const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug); @@ -445,7 +445,7 @@ function mapStateToProps(state, ownProps) { hasChanged, displayUrl, hasWorkflow, - useForkWorkflow, + useOpenAuthoring, isModification, collectionEntriesLoaded, currentStatus, diff --git a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js index 1b19d808..08cbbb82 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js @@ -166,7 +166,7 @@ class EditorInterface extends Component { hasChanged, displayUrl, hasWorkflow, - useForkWorkflow, + useOpenAuthoring, hasUnpublishedChanges, isNewEntry, isModification, @@ -241,7 +241,7 @@ class EditorInterface extends Component { displayUrl={displayUrl} collection={collection} hasWorkflow={hasWorkflow} - useForkWorkflow={useForkWorkflow} + useOpenAuthoring={useOpenAuthoring} hasUnpublishedChanges={hasUnpublishedChanges} isNewEntry={isNewEntry} isModification={isModification} @@ -295,7 +295,7 @@ EditorInterface.propTypes = { hasChanged: PropTypes.bool, displayUrl: PropTypes.string, hasWorkflow: PropTypes.bool, - useForkWorkflow: PropTypes.bool, + useOpenAuthoring: PropTypes.bool, hasUnpublishedChanges: PropTypes.bool, isNewEntry: PropTypes.bool, isModification: PropTypes.bool, diff --git a/packages/netlify-cms-core/src/components/Editor/EditorToolbar.js b/packages/netlify-cms-core/src/components/Editor/EditorToolbar.js index c330bcfd..e1f991de 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorToolbar.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorToolbar.js @@ -218,7 +218,7 @@ class EditorToolbar extends React.Component { displayUrl: PropTypes.string, collection: ImmutablePropTypes.map.isRequired, hasWorkflow: PropTypes.bool, - useForkWorkflow: PropTypes.bool, + useOpenAuthoring: PropTypes.bool, hasUnpublishedChanges: PropTypes.bool, isNewEntry: PropTypes.bool, isModification: PropTypes.bool, @@ -380,7 +380,7 @@ class EditorToolbar extends React.Component { onPublishAndNew, currentStatus, isNewEntry, - useForkWorkflow, + useOpenAuthoring, t, } = this.props; if (currentStatus) { @@ -408,7 +408,7 @@ class EditorToolbar extends React.Component { onClick={() => onChangeStatus('PENDING_REVIEW')} icon={currentStatus === status.get('PENDING_REVIEW') && 'check'} /> - {useForkWorkflow ? ( + {useOpenAuthoring ? ( '' ) : ( )} - {useForkWorkflow ? ( + {useOpenAuthoring ? ( '' ) : ( ); @@ -137,8 +137,8 @@ class Workflow extends Component { function mapStateToProps(state) { const { collections, config, globalUI } = state; const isEditorialWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW; - const isForkWorkflow = globalUI.get('useForkWorkflow', false); - const returnObj = { collections, isEditorialWorkflow, isForkWorkflow }; + const isOpenAuthoring = globalUI.get('useOpenAuthoring', false); + const returnObj = { collections, isEditorialWorkflow, isOpenAuthoring }; if (isEditorialWorkflow) { returnObj.isFetching = state.editorialWorkflow.getIn(['pages', 'isFetching'], false); diff --git a/packages/netlify-cms-core/src/components/Workflow/WorkflowList.js b/packages/netlify-cms-core/src/components/Workflow/WorkflowList.js index dbb6d142..7f4635fc 100644 --- a/packages/netlify-cms-core/src/components/Workflow/WorkflowList.js +++ b/packages/netlify-cms-core/src/components/Workflow/WorkflowList.js @@ -17,7 +17,7 @@ const WorkflowListContainer = styled.div` grid-template-columns: 33.3% 33.3% 33.3%; `; -const WorkflowListContainerForkWorkflow = styled.div` +const WorkflowListContainerOpenAuthoring = styled.div` min-height: 60%; display: grid; grid-template-columns: 50% 50% 0%; @@ -134,7 +134,7 @@ class WorkflowList extends React.Component { handlePublish: PropTypes.func.isRequired, handleDelete: PropTypes.func.isRequired, t: PropTypes.func.isRequired, - isForkWorkflow: PropTypes.bool, + isOpenAuthoring: PropTypes.bool, }; handleChangeStatus = (newStatus, dragProps) => { @@ -162,7 +162,7 @@ class WorkflowList extends React.Component { // eslint-disable-next-line react/display-name renderColumns = (entries, column) => { - const { isForkWorkflow } = this.props; + const { isOpenAuthoring } = this.props; if (!entries) return null; if (!column) { @@ -180,8 +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, + isOpenAuthoring && currColumn === 'pending_publish' && styles.hiddenColumn, + isOpenAuthoring && currColumn === 'pending_review' && styles.hiddenRightBorder, ]} > @@ -248,8 +248,8 @@ class WorkflowList extends React.Component { render() { const columns = this.renderColumns(this.props.entries); - const ListContainer = this.props.isForkWorkflow - ? WorkflowListContainerForkWorkflow + const ListContainer = this.props.isOpenAuthoring + ? WorkflowListContainerOpenAuthoring : WorkflowListContainer; return {columns}; } diff --git a/packages/netlify-cms-core/src/reducers/globalUI.js b/packages/netlify-cms-core/src/reducers/globalUI.js index dfe6a559..78c30d05 100644 --- a/packages/netlify-cms-core/src/reducers/globalUI.js +++ b/packages/netlify-cms-core/src/reducers/globalUI.js @@ -1,16 +1,16 @@ import { Map } from 'immutable'; -import { USE_FORK_WORKFLOW } from 'Actions/auth'; +import { USE_OPEN_AUTHORING } from 'Actions/auth'; /* * Reducer for some global UI state that we want to share between components * */ -const globalUI = (state = Map({ isFetching: false, useForkWorkflow: false }), action) => { +const globalUI = (state = Map({ isFetching: false, useOpenAuthoring: 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); + } else if (action.type === USE_OPEN_AUTHORING) { + return state.set('useOpenAuthoring', true); } return state; }; diff --git a/packages/netlify-cms-lib-util/src/backendUtil.js b/packages/netlify-cms-lib-util/src/backendUtil.js index 80817762..a04ca2e6 100644 --- a/packages/netlify-cms-lib-util/src/backendUtil.js +++ b/packages/netlify-cms-lib-util/src/backendUtil.js @@ -1,6 +1,8 @@ -import { get } from 'lodash'; +import { flow, fromPairs, get } from 'lodash'; +import { map } from 'lodash/fp'; import { fromJS } from 'immutable'; import { fileExtension } from './path'; +import unsentRequest from './unsentRequest'; export const filterByPropExtension = (extension, propName) => arr => arr.filter(el => fileExtension(get(el, propName)) === extension); @@ -40,3 +42,36 @@ export const parseResponse = async (res, { expectingOk = true, format = 'text' } }; export const responseParser = options => res => parseResponse(res, options); + +export const parseLinkHeader = flow([ + linksString => linksString.split(','), + map(str => str.trim().split(';')), + map(([linkStr, keyStr]) => [ + keyStr.match(/rel="(.*?)"/)[1], + linkStr + .trim() + .match(/<(.*?)>/)[1] + .replace(/\+/g, '%20'), + ]), + fromPairs, +]); + +export const getPaginatedRequestIterator = (url, options = {}, linkHeaderRelName = 'next') => { + let req = unsentRequest.fromFetchArguments(url, options); + const next = async () => { + if (!req) { + return { done: true }; + } + + const pageResponse = await unsentRequest.performRequest(req); + const linkHeader = pageResponse.headers.get('Link'); + const nextURL = linkHeader && parseLinkHeader(linkHeader)[linkHeaderRelName]; + req = nextURL && unsentRequest.fromURL(nextURL); + return { value: pageResponse }; + }; + return { + [Symbol.asyncIterator]: () => ({ + next, + }), + }; +}; diff --git a/packages/netlify-cms-lib-util/src/index.js b/packages/netlify-cms-lib-util/src/index.js index 1ad8ecdf..48283aa3 100644 --- a/packages/netlify-cms-lib-util/src/index.js +++ b/packages/netlify-cms-lib-util/src/index.js @@ -11,7 +11,13 @@ import { then, } from './promise'; import unsentRequest from './unsentRequest'; -import { filterByPropExtension, parseResponse, responseParser } from './backendUtil'; +import { + filterByPropExtension, + getPaginatedRequestIterator, + parseLinkHeader, + parseResponse, + responseParser, +} from './backendUtil'; import loadScript from './loadScript'; import getBlobSHA from './getBlobSHA'; @@ -33,6 +39,7 @@ export const NetlifyCmsLibUtil = { then, unsentRequest, filterByPropExtension, + parseLinkHeader, parseResponse, responseParser, loadScript, @@ -56,6 +63,8 @@ export { then, unsentRequest, filterByPropExtension, + parseLinkHeader, + getPaginatedRequestIterator, parseResponse, responseParser, loadScript, diff --git a/packages/netlify-cms-lib-util/src/unsentRequest.js b/packages/netlify-cms-lib-util/src/unsentRequest.js index 2f735725..ac94a693 100644 --- a/packages/netlify-cms-lib-util/src/unsentRequest.js +++ b/packages/netlify-cms-lib-util/src/unsentRequest.js @@ -13,6 +13,12 @@ const fromURL = wholeURL => { return Map({ url, ...(allParamsString ? { params: decodeParams(allParamsString) } : {}) }); }; +const fromFetchArguments = (wholeURL, options) => { + return fromURL(wholeURL).merge( + (options ? fromJS(options) : Map()).remove('url').remove('params'), + ); +}; + const encodeParams = params => params .entrySeq() @@ -25,8 +31,8 @@ const toURL = req => const toFetchArguments = req => [ toURL(req), req - .delete('url') - .delete('params') + .remove('url') + .remove('params') .toJS(), ]; @@ -85,6 +91,7 @@ const withTimestamp = ensureRequestArg(req => withParams({ ts: new Date().getTim export default { toURL, fromURL, + fromFetchArguments, performRequest, withMethod, withDefaultMethod, diff --git a/website/content/docs/open-authoring.md b/website/content/docs/open-authoring.md index cec28fc6..7dc6208b 100644 --- a/website/content/docs/open-authoring.md +++ b/website/content/docs/open-authoring.md @@ -17,17 +17,17 @@ At the same time, any contributors who _do_ have write access to the repository - Your repo on GitHub must be public. -## Enabling the Open Authoring +## Enabling Open Authoring 1. [Enable the editorial workflow](/docs/configuration-options/#publish-mode) by setting `publish_mode` to `editorial_workflow` in your `config.yml`. -2. Set `fork_workflow` to `true` in the `backend` section of your `config.yml`, as follows: +2. Set `open_authoring` to `true` in the `backend` section of your `config.yml`, as follows: ```yaml backend: name: github repo: owner-name/repo-name # Path to your GitHub repository - fork_workflow: true + open_authoring: true ``` ## Usage