diff --git a/.changeset/flat-toys-roll.md b/.changeset/flat-toys-roll.md new file mode 100644 index 00000000..a733abf4 --- /dev/null +++ b/.changeset/flat-toys-roll.md @@ -0,0 +1,5 @@ +--- +'@smartcontractkit/operator-ui': patch +--- + +feat: allow approval of previous versions of job specs diff --git a/src/screens/JobProposal/SpecsView.test.tsx b/src/screens/JobProposal/SpecsView.test.tsx index 9ea34ece..5624d704 100644 --- a/src/screens/JobProposal/SpecsView.test.tsx +++ b/src/screens/JobProposal/SpecsView.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { render, screen, waitFor } from 'support/test-utils' +import { render, screen, waitFor, within } from 'support/test-utils' import userEvent from '@testing-library/user-event' import { @@ -260,4 +260,151 @@ describe('SpecsView', () => { expect(queryByText('Cancel')).not.toBeInTheDocument() }) }) + + describe('Approve button', () => { + let specs: ReadonlyArray + let proposal: JobProposalPayloadFields + + beforeEach(() => { + specs = [ + buildJobProposalSpec({ id: '106', version: 6, status: 'CANCELLED' }), + buildJobProposalSpec({ id: '105', version: 5, status: 'PENDING' }), + buildJobProposalSpec({ id: '104', version: 4, status: 'CANCELLED' }), + buildJobProposalSpec({ id: '103', version: 3, status: 'CANCELLED' }), + buildJobProposalSpec({ id: '102', version: 2, status: 'APPROVED' }), + buildJobProposalSpec({ id: '101', version: 1, status: 'REVOKED' }), + ] + }) + + it('is visible in latest two cancelled specs if proposal is not deleted or revoked', async () => { + proposal = buildJobProposal({ status: 'PENDING' }) + renderComponent(specs, proposal) + + const panels = screen.getAllByTestId('expansion-panel') + expect(panels).toHaveLength(6) + expect( + within(panels[0]).queryByRole('button', { + name: 'Approve', + hidden: false, + }), + ).toBeInTheDocument() + expect( + within(panels[1]).queryByRole('button', { + name: 'Approve', + hidden: true, + }), + ).not.toBeInTheDocument() + expect( + within(panels[2]).queryByRole('button', { + name: 'Approve', + hidden: true, + }), + ).toBeInTheDocument() + expect( + within(panels[3]).queryByRole('button', { + name: 'Approve', + hidden: true, + }), + ).not.toBeInTheDocument() + expect( + within(panels[4]).queryByRole('button', { + name: 'Approve', + hidden: true, + }), + ).not.toBeInTheDocument() + expect( + within(panels[5]).queryByRole('button', { + name: 'Approve', + hidden: true, + }), + ).not.toBeInTheDocument() + }) + + it('is visible in latest pending spec if proposal is not deleted or revoked', async () => { + specs = [ + buildJobProposalSpec({ id: '103', version: 3, status: 'PENDING' }), + buildJobProposalSpec({ id: '102', version: 2, status: 'PENDING' }), + buildJobProposalSpec({ id: '101', version: 1, status: 'CANCELLED' }), + ] + proposal = buildJobProposal({ status: 'PENDING' }) + renderComponent(specs, proposal) + + const panels = screen.getAllByTestId('expansion-panel') + expect(panels).toHaveLength(3) + expect( + within(panels[0]).queryByRole('button', { + name: 'Approve', + hidden: false, + }), + ).toBeInTheDocument() + expect( + within(panels[1]).queryByRole('button', { + name: 'Approve', + hidden: true, + }), + ).not.toBeInTheDocument() + expect( + within(panels[2]).queryByRole('button', { + name: 'Approve', + hidden: true, + }), + ).toBeInTheDocument() + }) + + it('is not visible in any specs if proposal is deleted', async () => { + proposal = buildJobProposal({ status: 'DELETED' }) + renderComponent(specs, proposal) + + const panels = screen.getAllByTestId('expansion-panel') + expect(panels).toHaveLength(6) + expect( + screen.queryByRole('button', { name: 'Approve', hidden: false }), + ).not.toBeInTheDocument() + }) + + it('is not visible in any specs if proposal is revoked', async () => { + proposal = buildJobProposal({ status: 'REVOKED' }) + renderComponent(specs, proposal) + + const panels = screen.getAllByTestId('expansion-panel') + expect(panels).toHaveLength(6) + expect( + screen.queryByRole('button', { name: 'Approve', hidden: false }), + ).not.toBeInTheDocument() + }) + + it('is visible with single pending job', async () => { + proposal = buildJobProposal({ status: 'PENDING' }) + specs = [ + buildJobProposalSpec({ id: '101', version: 1, status: 'PENDING' }), + ] + renderComponent(specs, proposal) + + const panels = screen.getAllByTestId('expansion-panel') + expect(panels).toHaveLength(1) + expect( + within(panels[0]).queryByRole('button', { + name: 'Approve', + hidden: false, + }), + ).toBeInTheDocument() + }) + + it('is visible with single cancelled job', async () => { + proposal = buildJobProposal({ status: 'PENDING' }) + specs = [ + buildJobProposalSpec({ id: '101', version: 1, status: 'CANCELLED' }), + ] + renderComponent(specs, proposal) + + const panels = screen.getAllByTestId('expansion-panel') + expect(panels).toHaveLength(1) + expect( + within(panels[0]).queryByRole('button', { + name: 'Approve', + hidden: false, + }), + ).toBeInTheDocument() + }) + }) }) diff --git a/src/screens/JobProposal/SpecsView.tsx b/src/screens/JobProposal/SpecsView.tsx index 0e54d71e..63ce586b 100644 --- a/src/screens/JobProposal/SpecsView.tsx +++ b/src/screens/JobProposal/SpecsView.tsx @@ -72,7 +72,7 @@ interface Props extends WithStyles { } interface ConfirmationDialogArgs { - action: 'reject' | 'approve' | 'cancel' + action: 'reject' | 'approve' | 'approvePrevious' | 'cancel' id: string } @@ -81,6 +81,25 @@ const confirmationDialogText = { title: 'Approve Job Proposal', body: 'Approving this job proposal will start running a new job. WARNING: If a job using the same contract address already exists, it will be deleted before running the new one.', }, + approvePrevious: { + title: 'Approve Previous Version of Job Proposal', + body: ( +
+

+ ⚠️ You have selected a job spec version that is not the most recent + one. +

+

+ Approving this job proposal will start running a new job with the old + spec version. +

+

+ WARNING: If a job using the same contract address already exists, it + will be deleted before running the new one. +

+
+ ), + }, cancel: { title: 'Cancel Job Proposal', body: 'Cancelling this job proposal will delete the running job. Are you sure you want to cancel this job proposal?', @@ -124,6 +143,11 @@ export const SpecsView = withStyles(styles)( return sorted.sort((a, b) => b.version - a.version) }, [specs]) + const approvableCancelledJobSpecs = sortedSpecs + .filter((spec) => spec.status === 'CANCELLED') + .slice(0, 2) + .map((spec) => spec.id) + const renderActions = ( status: SpecStatus, specID: string, @@ -193,7 +217,7 @@ export const SpecsView = withStyles(styles)( ) case 'CANCELLED': if ( - latestSpec.id === specID && + approvableCancelledJobSpecs.includes(specID) && proposal.status !== 'DELETED' && proposal.status !== 'REVOKED' ) { @@ -201,7 +225,12 @@ export const SpecsView = withStyles(styles)( @@ -217,7 +246,11 @@ export const SpecsView = withStyles(styles)( return (
{sortedSpecs.map((spec, idx) => ( - + }> Version {spec.version} @@ -285,6 +318,7 @@ export const SpecsView = withStyles(styles)( if (confirmationDialog) { switch (confirmationDialog.action) { case 'approve': + case 'approvePrevious': onApprove(confirmationDialog.id) break