diff --git a/packages/netlify-cms-backend-azure/src/API.ts b/packages/netlify-cms-backend-azure/src/API.ts index bbc1668a7269..d1ef8c7144b6 100644 --- a/packages/netlify-cms-backend-azure/src/API.ts +++ b/packages/netlify-cms-backend-azure/src/API.ts @@ -462,8 +462,10 @@ export default class API { const status = labelToStatus(labelName, this.cmsLabelPrefix); // Uses creationDate, as we do not have direct access to the updated date const updatedAt = pullRequest.closedDate ? pullRequest.closedDate : pullRequest.creationDate; - const pullRequestAuthor = - pullRequest.createdBy?.displayName || pullRequest.createdBy?.uniqueName; + const pullRequestAuthor = { + name: pullRequest.createdBy?.displayName, + login: pullRequest.createdBy?.uniqueName, + }; return { collection, slug, @@ -717,6 +719,22 @@ export default class API { await this.completePullRequest(pullRequest); } + async approveEntry(collectionName: string, slug: string) { + const contentKey = generateContentKey(collectionName, slug); + const branch = branchFromContentKey(contentKey); + + const pullRequest = await this.getBranchPullRequest(branch); + const pullRequestCompleted = { + status: AzurePullRequestStatus.COMPLETED, + }; + + await this.requestJSON({ + method: 'PATCH', + url: `${this.endpointUrl}/pullrequests/${encodeURIComponent(pullRequest.pullRequestId)}`, + body: JSON.stringify(pullRequestCompleted), + }); + } + async updatePullRequestLabels(pullRequest: AzurePullRequest, labels: string[]) { const cmsLabels = pullRequest.labels.filter(l => isCMSLabel(l.name, this.cmsLabelPrefix)); await Promise.all( diff --git a/packages/netlify-cms-backend-azure/src/implementation.ts b/packages/netlify-cms-backend-azure/src/implementation.ts index b80ad80e118e..d95529855419 100644 --- a/packages/netlify-cms-backend-azure/src/implementation.ts +++ b/packages/netlify-cms-backend-azure/src/implementation.ts @@ -365,6 +365,15 @@ export default class Azure implements Implementation { ); } + approveEntry(collection: string, slug: string) { + // approveEntry is a transactional operation + return runWithLock( + this.lock, + () => this.api!.approveEntry(collection, slug), + 'Failed to acquire approve entry lock', + ); + } + async getDeployPreview(collection: string, slug: string) { try { const statuses = await this.api!.getStatuses(collection, slug); diff --git a/packages/netlify-cms-backend-bitbucket/src/API.ts b/packages/netlify-cms-backend-bitbucket/src/API.ts index 5e1ec935ef47..a02aeb3fc70c 100644 --- a/packages/netlify-cms-backend-bitbucket/src/API.ts +++ b/packages/netlify-cms-backend-bitbucket/src/API.ts @@ -700,7 +700,10 @@ export default class API { const label = await this.getPullRequestLabel(pullRequest.id); const status = labelToStatus(label, this.cmsLabelPrefix); const updatedAt = pullRequest.updated_on; - const pullRequestAuthor = pullRequest.author.display_name; + const pullRequestAuthor = { + name: pullRequest.author.display_name, + login: pullRequest.author.username, + }; return { collection, slug, @@ -743,6 +746,17 @@ export default class API { await this.mergePullRequest(pullRequest); } + async approveEntry(collectionName: string, slug: string) { + const contentKey = generateContentKey(collectionName, slug); + const branch = branchFromContentKey(contentKey); + + const pullRequest = await this.getBranchPullRequest(branch); + await this.requestJSON({ + method: 'POST', + url: `${this.repoURL}/pullrequests/${pullRequest.id}/approve`, + }); + } + async declinePullRequest(pullRequest: BitBucketPullRequest) { await this.requestJSON({ method: 'POST', diff --git a/packages/netlify-cms-backend-bitbucket/src/implementation.ts b/packages/netlify-cms-backend-bitbucket/src/implementation.ts index 30375ad6715a..881323bcbf73 100644 --- a/packages/netlify-cms-backend-bitbucket/src/implementation.ts +++ b/packages/netlify-cms-backend-bitbucket/src/implementation.ts @@ -612,6 +612,15 @@ export default class BitbucketBackend implements Implementation { ); } + approveEntry(collection: string, slug: string) { + // approveEntry is a transactional operation + return runWithLock( + this.lock, + () => this.api!.approveEntry(collection, slug), + 'Failed to acquire approve entry lock', + ); + } + async getDeployPreview(collection: string, slug: string) { try { const statuses = await this.api!.getStatuses(collection, slug); diff --git a/packages/netlify-cms-backend-github/src/API.ts b/packages/netlify-cms-backend-github/src/API.ts index 449b95b027f6..c955b8fc24f9 100644 --- a/packages/netlify-cms-backend-github/src/API.ts +++ b/packages/netlify-cms-backend-github/src/API.ts @@ -576,8 +576,8 @@ export default class API { } try { - const user: GitHubUser = await this.request(`/users/${pullRequest.user.login}`); - return user.name || user.login; + const { name, login }: GitHubUser = await this.request(`/users/${pullRequest.user.login}`); + return { name, login }; } catch { return; } @@ -1165,6 +1165,14 @@ export default class API { await this.deleteBranch(branch); } + async approveEntry(collectionName: string, slug: string) { + const contentKey = this.generateContentKey(collectionName, slug); + const branch = branchFromContentKey(contentKey); + + const pullRequest = await this.getBranchPullRequest(branch); + await this.approvePR(pullRequest); + } + async createRef(type: string, name: string, sha: string) { const result: Octokit.GitCreateRefResponse = await this.request(`${this.repoURL}/git/refs`, { method: 'POST', @@ -1341,6 +1349,20 @@ export default class API { } } + async approvePR(pullrequest: GitHubPull) { + console.log('%c Approving PR', 'line-height: 30px;text-align: center;font-weight: bold'); + const result: Octokit.PullsCreateReviewResponse = await this.request( + `${this.originRepoURL}/pulls/${pullrequest.number}/reviews`, + { + method: 'POST', + body: JSON.stringify({ + event: 'APPROVE', + }), + }, + ); + return result; + } + async forceMergePR(pullRequest: GitHubPull) { const result = await this.getDifferences(pullRequest.base.sha, pullRequest.head.sha); const files = getTreeFiles(result.files as GitHubCompareFiles); diff --git a/packages/netlify-cms-backend-github/src/GraphQLAPI.ts b/packages/netlify-cms-backend-github/src/GraphQLAPI.ts index 23090dd2d281..cd2d6842f21d 100644 --- a/packages/netlify-cms-backend-github/src/GraphQLAPI.ts +++ b/packages/netlify-cms-backend-github/src/GraphQLAPI.ts @@ -281,8 +281,11 @@ export default class GraphQLAPI extends API { } async getPullRequestAuthor(pullRequest: Octokit.PullsListResponseItem) { - const user = pullRequest.user as unknown as GraphQLPullsListResponseItemUser; - return user?.name || user?.login; + if (!pullRequest.user) { + return { name: '', login: '' }; + } + const { name, login } = pullRequest.user as unknown as GraphQLPullsListResponseItemUser; + return { name, login }; } async getPullRequests( diff --git a/packages/netlify-cms-backend-github/src/implementation.tsx b/packages/netlify-cms-backend-github/src/implementation.tsx index 4b4e1b3c590e..c9c67bc3a505 100644 --- a/packages/netlify-cms-backend-github/src/implementation.tsx +++ b/packages/netlify-cms-backend-github/src/implementation.tsx @@ -670,4 +670,13 @@ export default class GitHub implements Implementation { 'Failed to acquire publish entry lock', ); } + + approveEntry(collection: string, slug: string) { + // approveEntry is a transactional operation + return runWithLock( + this.lock, + () => this.api!.approveEntry(collection, slug), + 'Failed to acquire approve entry lock', + ); + } } diff --git a/packages/netlify-cms-backend-gitlab/src/API.ts b/packages/netlify-cms-backend-gitlab/src/API.ts index 8dc88f8dc16c..d41d5235180d 100644 --- a/packages/netlify-cms-backend-gitlab/src/API.ts +++ b/packages/netlify-cms-backend-gitlab/src/API.ts @@ -809,7 +809,10 @@ export default class API { const label = mergeRequest.labels.find(l => isCMSLabel(l, this.cmsLabelPrefix)) as string; const status = labelToStatus(label, this.cmsLabelPrefix); const updatedAt = mergeRequest.updated_at; - const pullRequestAuthor = mergeRequest.author.name; + const pullRequestAuthor = { + name: mergeRequest.author.name, + login: mergeRequest.author.username, + }; return { collection, slug, @@ -947,6 +950,17 @@ export default class API { await this.mergeMergeRequest(mergeRequest); } + async approveEntry(collectionName: string, slug: string) { + const contentKey = generateContentKey(collectionName, slug); + const branch = branchFromContentKey(contentKey); + + const mergeRequest = await this.getBranchMergeRequest(branch); + await this.requestJSON({ + method: 'POST', + url: `${this.repoURL}/merge_requests/${mergeRequest.iid}/approve`, + }); + } + async closeMergeRequest(mergeRequest: GitLabMergeRequest) { await this.requestJSON({ method: 'PUT', diff --git a/packages/netlify-cms-backend-gitlab/src/implementation.ts b/packages/netlify-cms-backend-gitlab/src/implementation.ts index bf9df133c885..1373b86b5a7f 100644 --- a/packages/netlify-cms-backend-gitlab/src/implementation.ts +++ b/packages/netlify-cms-backend-gitlab/src/implementation.ts @@ -438,6 +438,15 @@ export default class GitLab implements Implementation { ); } + approveEntry(collection: string, slug: string) { + // approveEntry is a transactional operation + return runWithLock( + this.lock, + () => this.api!.approveEntry(collection, slug), + 'Failed to acquire approve entry lock', + ); + } + async getDeployPreview(collection: string, slug: string) { try { const statuses = await this.api!.getStatuses(collection, slug); diff --git a/packages/netlify-cms-core/src/__tests__/backend.spec.js b/packages/netlify-cms-core/src/__tests__/backend.spec.js index 8b7766ec4a75..3f0a6672a0be 100644 --- a/packages/netlify-cms-core/src/__tests__/backend.spec.js +++ b/packages/netlify-cms-core/src/__tests__/backend.spec.js @@ -202,6 +202,8 @@ describe('Backend', () => { isModification: null, status: '', updatedOn: '', + canApprove: false, + supportsApprove: false, }, }); expect(localForage.getItem).toHaveBeenCalledTimes(1); @@ -243,6 +245,8 @@ describe('Backend', () => { isModification: null, status: '', updatedOn: '', + canApprove: false, + supportsApprove: false, }, }); expect(localForage.getItem).toHaveBeenCalledTimes(1); @@ -392,6 +396,8 @@ describe('Backend', () => { mediaFiles: [{ id: '1', draft: true }], status: '', updatedOn: '', + canApprove: false, + supportsApprove: false, }); }); }); diff --git a/packages/netlify-cms-core/src/actions/__tests__/entries.spec.js b/packages/netlify-cms-core/src/actions/__tests__/entries.spec.js index a80aa2e8cedc..83749dc5e984 100644 --- a/packages/netlify-cms-core/src/actions/__tests__/entries.spec.js +++ b/packages/netlify-cms-core/src/actions/__tests__/entries.spec.js @@ -60,6 +60,8 @@ describe('entries', () => { slug: '', status: '', updatedOn: '', + canApprove: false, + supportsApprove: false, }, type: 'DRAFT_CREATE_EMPTY', }); @@ -93,6 +95,8 @@ describe('entries', () => { slug: '', status: '', updatedOn: '', + canApprove: false, + supportsApprove: false, }, type: 'DRAFT_CREATE_EMPTY', }); @@ -128,6 +132,8 @@ describe('entries', () => { slug: '', status: '', updatedOn: '', + canApprove: false, + supportsApprove: false, }, type: 'DRAFT_CREATE_EMPTY', }); diff --git a/packages/netlify-cms-core/src/actions/editorialWorkflow.ts b/packages/netlify-cms-core/src/actions/editorialWorkflow.ts index 52d9a8dd53ae..dc52a0c08fb6 100644 --- a/packages/netlify-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/netlify-cms-core/src/actions/editorialWorkflow.ts @@ -61,7 +61,10 @@ export const UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS = 'UNPUBLISHED_ENTRY_STATUS export const UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE'; export const UNPUBLISHED_ENTRY_PUBLISH_REQUEST = 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST'; +export const APPROVE_ENTRY_REQUEST = 'APPROVE_ENTRY_REQUEST'; export const UNPUBLISHED_ENTRY_PUBLISH_SUCCESS = 'UNPUBLISHED_ENTRY_PUBLISH_SUCCESS'; +export const APPROVE_ENTRY_SUCCESS = 'APPROVE_ENTRY_SUCCESS'; +export const APPROVE_ENTRY_FAILURE = 'APPROVE_ENTRY_FAILURE'; export const UNPUBLISHED_ENTRY_PUBLISH_FAILURE = 'UNPUBLISHED_ENTRY_PUBLISH_FAILURE'; export const UNPUBLISHED_ENTRY_DELETE_REQUEST = 'UNPUBLISHED_ENTRY_DELETE_REQUEST'; @@ -200,6 +203,13 @@ function unpublishedEntryPublishRequest(collection: string, slug: string) { }; } +function approveEntryRequest(collection: string, slug: string) { + return { + type: APPROVE_ENTRY_REQUEST, + payload: { collection, slug }, + }; +} + function unpublishedEntryPublished(collection: string, slug: string) { return { type: UNPUBLISHED_ENTRY_PUBLISH_SUCCESS, @@ -207,6 +217,13 @@ function unpublishedEntryPublished(collection: string, slug: string) { }; } +function entryApproved(collection: string, slug: string) { + return { + type: APPROVE_ENTRY_SUCCESS, + payload: { collection, slug }, + }; +} + function unpublishedEntryPublishError(collection: string, slug: string) { return { type: UNPUBLISHED_ENTRY_PUBLISH_FAILURE, @@ -214,6 +231,13 @@ function unpublishedEntryPublishError(collection: string, slug: string) { }; } +function entryApproveError(collection: string, slug: string) { + return { + type: APPROVE_ENTRY_FAILURE, + payload: { collection, slug }, + }; +} + function unpublishedEntryDeleteRequest(collection: string, slug: string) { return { type: UNPUBLISHED_ENTRY_DELETE_REQUEST, @@ -524,6 +548,35 @@ export function publishUnpublishedEntry(collectionName: string, slug: string) { }; } +export function approveEntry(collectionName: string, slug: string) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + const state = getState(); + const backend = currentBackend(state.config); + const entry = selectUnpublishedEntry(state, collectionName, slug); + dispatch(approveEntryRequest(collectionName, slug)); + try { + await backend.approveEntry(entry); + dispatch( + notifSend({ + message: { key: 'ui.toast.entryApproved' }, + kind: 'success', + dismissAfter: 4000, + }), + ); + dispatch(entryApproved(collectionName, slug)); + } catch (error) { + dispatch( + notifSend({ + message: { key: 'ui.toast.onFailToApproveEntry', details: error }, + kind: 'danger', + dismissAfter: 8000, + }), + ); + dispatch(entryApproveError(collectionName, slug)); + } + }; +} + export function unpublishPublishedEntry(collection: Collection, slug: string) { return (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index b328dac88133..aada0c31e805 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -882,9 +882,13 @@ export class Backend { label: collection && selectFileEntryLabel(collection, slug), mediaFiles, updatedOn: entryData.updatedAt, - author: entryData.pullRequestAuthor, + author: entryData.pullRequestAuthor?.name || entryData.pullRequestAuthor?.login, status: entryData.status, meta: { path: prepareMetaPath(path, collection) }, + supportsApprove: this.isGitBackend(), + canApprove: this.user?.login + ? this.user?.login !== entryData.pullRequestAuthor?.login + : false, }); const entryWithFormat = this.entryWithFormat(collection)(entry); @@ -1268,6 +1272,13 @@ export class Backend { await this.invokePostPublishEvent(entry); } + async approveEntry(entry: EntryMap) { + const collection = entry.get('collection'); + const slug = entry.get('slug'); + + await this.implementation.approveEntry!(collection, slug); + } + deleteUnpublishedEntry(collection: string, slug: string) { return this.implementation.deleteUnpublishedEntry!(collection, slug); } diff --git a/packages/netlify-cms-core/src/components/Workflow/Workflow.js b/packages/netlify-cms-core/src/components/Workflow/Workflow.js index eaf261fad8d9..228ee77d2c1e 100644 --- a/packages/netlify-cms-core/src/components/Workflow/Workflow.js +++ b/packages/netlify-cms-core/src/components/Workflow/Workflow.js @@ -20,6 +20,7 @@ import { loadUnpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry, + approveEntry, deleteUnpublishedEntry, } from '../../actions/editorialWorkflow'; import { selectUnpublishedEntriesByStatus } from '../../reducers'; @@ -62,6 +63,7 @@ class Workflow extends Component { loadUnpublishedEntries: PropTypes.func.isRequired, updateUnpublishedEntryStatus: PropTypes.func.isRequired, publishUnpublishedEntry: PropTypes.func.isRequired, + approveEntry: PropTypes.func.isRequired, deleteUnpublishedEntry: PropTypes.func.isRequired, t: PropTypes.func.isRequired, }; @@ -81,6 +83,7 @@ class Workflow extends Component { unpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry, + approveEntry, deleteUnpublishedEntry, collections, t, @@ -127,6 +130,7 @@ class Workflow extends Component { entries={unpublishedEntries} handleChangeStatus={updateUnpublishedEntryStatus} handlePublish={publishUnpublishedEntry} + handleApprove={approveEntry} handleDelete={deleteUnpublishedEntry} isOpenAuthoring={isOpenAuthoring} collections={collections} @@ -162,5 +166,6 @@ export default connect(mapStateToProps, { loadUnpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry, + approveEntry, deleteUnpublishedEntry, })(translate()(Workflow)); diff --git a/packages/netlify-cms-core/src/components/Workflow/WorkflowCard.js b/packages/netlify-cms-core/src/components/Workflow/WorkflowCard.js index 0ca914afe88d..abb8ee04a898 100644 --- a/packages/netlify-cms-core/src/components/Workflow/WorkflowCard.js +++ b/packages/netlify-cms-core/src/components/Workflow/WorkflowCard.js @@ -86,6 +86,17 @@ const PublishButton = styled.button` } `; +const ApproveButton = styled.button` + ${styles.button}; + background-color: ${colorsRaw.green}; + margin-left: 6px; + color: ${colors.textLight}; + + &[disabled] { + ${buttons.disabled}; + } +`; + const WorkflowCardContainer = styled.div` ${components.card}; margin-bottom: 24px; @@ -128,7 +139,10 @@ function WorkflowCard({ allowPublish, canPublish, onPublish, + onApprove, postAuthor, + canApprove, + supportsApprove, t, }) { return ( @@ -153,6 +167,13 @@ function WorkflowCard({ : t('workflow.workflowCard.publishNewEntry')} )} + {supportsApprove && ( + <> + + {t('workflow.workflowCard.approveEntry')} + + + )} ); @@ -171,6 +192,8 @@ WorkflowCard.propTypes = { canPublish: PropTypes.bool.isRequired, onPublish: PropTypes.func.isRequired, postAuthor: PropTypes.string, + canApprove: PropTypes.bool.isRequired, + supportsApprove: PropTypes.bool.isRequired, t: PropTypes.func.isRequired, }; diff --git a/packages/netlify-cms-core/src/components/Workflow/WorkflowList.js b/packages/netlify-cms-core/src/components/Workflow/WorkflowList.js index 7e0a9a7ba608..3e8ad2012347 100644 --- a/packages/netlify-cms-core/src/components/Workflow/WorkflowList.js +++ b/packages/netlify-cms-core/src/components/Workflow/WorkflowList.js @@ -162,6 +162,10 @@ class WorkflowList extends React.Component { this.props.handlePublish(collection, slug); }; + requestApprove = (collection, slug) => { + this.props.handleApprove(collection, slug); + }; + // eslint-disable-next-line react/display-name renderColumns = (entries, column) => { const { isOpenAuthoring, collections, t } = this.props; @@ -221,6 +225,8 @@ class WorkflowList extends React.Component { const allowPublish = collection?.get('publish'); const canPublish = ownStatus === status.last() && !entry.get('isPersisting', false); const postAuthor = entry.get('author'); + const supportsApprove = entry.get('supportsApprove'); + const canApprove = entry.get('canApprove'); return ( , ) diff --git a/packages/netlify-cms-core/src/valueObjects/Entry.ts b/packages/netlify-cms-core/src/valueObjects/Entry.ts index 6507215ec594..1ab9bb9437e8 100644 --- a/packages/netlify-cms-core/src/valueObjects/Entry.ts +++ b/packages/netlify-cms-core/src/valueObjects/Entry.ts @@ -18,6 +18,8 @@ interface Options { // eslint-disable-next-line @typescript-eslint/no-explicit-any [locale: string]: any; }; + supportsApprove?: boolean; + canApprove?: boolean; } export interface EntryValue { @@ -39,6 +41,8 @@ export interface EntryValue { // eslint-disable-next-line @typescript-eslint/no-explicit-any [locale: string]: any; }; + supportsApprove: boolean; + canApprove: boolean; } export function createEntry(collection: string, slug = '', path = '', options: Options = {}) { @@ -57,6 +61,8 @@ export function createEntry(collection: string, slug = '', path = '', options: O status: options.status || '', meta: options.meta || {}, i18n: options.i18n || {}, + canApprove: options.canApprove || false, + supportsApprove: options.supportsApprove || false, }; return returnObj; diff --git a/packages/netlify-cms-lib-util/src/implementation.ts b/packages/netlify-cms-lib-util/src/implementation.ts index 45542382e7ac..7b77141df646 100644 --- a/packages/netlify-cms-lib-util/src/implementation.ts +++ b/packages/netlify-cms-lib-util/src/implementation.ts @@ -40,7 +40,7 @@ export interface UnpublishedEntryDiff { } export interface UnpublishedEntry { - pullRequestAuthor?: string; + pullRequestAuthor?: { name?: string; login?: string }; slug: string; collection: string; status: string; @@ -167,6 +167,7 @@ export interface Implementation { newStatus: string, ) => Promise; publishUnpublishedEntry: (collection: string, slug: string) => Promise; + approveEntry?: (collection: string, slug: string) => Promise; deleteUnpublishedEntry: (collection: string, slug: string) => Promise; getDeployPreview: ( collectionName: string, diff --git a/packages/netlify-cms-locales/src/en/index.js b/packages/netlify-cms-locales/src/en/index.js index 400eaf6be0af..630638867b73 100644 --- a/packages/netlify-cms-locales/src/en/index.js +++ b/packages/netlify-cms-locales/src/en/index.js @@ -271,8 +271,10 @@ const en = { missingRequiredField: "Oops, you've missed a required field. Please complete before saving.", entrySaved: 'Entry saved', entryPublished: 'Entry published', + entryApproved: 'Entry approved', entryUnpublished: 'Entry unpublished', onFailToPublishEntry: 'Failed to publish: %{details}', + onFailToApproveEntry: 'Failed to approve: %{details}', onFailToUnpublishEntry: 'Failed to unpublish entry: %{details}', entryUpdated: 'Entry status updated', onDeleteUnpublishedChanges: 'Unpublished changes deleted', @@ -299,6 +301,8 @@ const en = { deleteNewEntry: 'Delete new entry', publishChanges: 'Publish changes', publishNewEntry: 'Publish new entry', + approveEntry: 'Approve entry', + cantApprove: "You can't approve this entry since you authored it", }, workflowList: { onDeleteEntry: 'Are you sure you want to delete this entry?',