From ee0b949707a495307bd26fb2810845bd0ef561fa Mon Sep 17 00:00:00 2001 From: motechFR Date: Fri, 25 Oct 2024 16:34:51 +0300 Subject: [PATCH] Proposals/selective publishing (#4885) * wip * Finish up UI * Cleanup based on PR feedback * Add test for concealing proposal workflow * Add test coverage * Add highlighting on the contract tab * Bump core --- .../components/common/SiteNavigation.tsx | 2 + .../RubricEvaluation/RubricEvaluation.tsx | 18 +- .../EvaluationAdvancedSettings.tsx | 52 ++++ .../EvaluationAppealSettings.tsx | 69 +++++ .../EvaluationRequiredReviews.tsx | 29 ++ .../ShowRubricResults.tsx | 29 ++ .../StepActionButtonLabel.tsx | 50 +++ .../StepFailReasonSelect.tsx | 79 +++++ .../proposals/components/EvaluationDialog.tsx | 290 +----------------- .../settings/proposals/components/form.ts | 27 ++ .../__tests__/applyProposalTemplate.spec.ts | 4 +- .../__tests__/concealProposalSteps.spec.ts | 63 ++++ lib/proposals/applyProposalTemplate.ts | 3 +- lib/proposals/applyProposalWorkflow.ts | 3 +- lib/proposals/concealProposalSteps.ts | 18 +- lib/proposals/createProposal.ts | 4 +- lib/proposals/getProposal.ts | 7 +- lib/proposals/mapDbProposalToProposal.ts | 30 +- lib/proposals/showRubricAnswersToAuthor.ts | 23 ++ .../workflows/upsertWorkflowTemplate.ts | 6 +- pages/api/proposals/[id]/index.ts | 2 +- 21 files changed, 505 insertions(+), 303 deletions(-) create mode 100644 components/settings/proposals/components/EvaluationAdvancedSettings/EvaluationAdvancedSettings.tsx create mode 100644 components/settings/proposals/components/EvaluationAdvancedSettings/EvaluationAppealSettings.tsx create mode 100644 components/settings/proposals/components/EvaluationAdvancedSettings/EvaluationRequiredReviews.tsx create mode 100644 components/settings/proposals/components/EvaluationAdvancedSettings/ShowRubricResults.tsx create mode 100644 components/settings/proposals/components/EvaluationAdvancedSettings/StepActionButtonLabel.tsx create mode 100644 components/settings/proposals/components/EvaluationAdvancedSettings/StepFailReasonSelect.tsx create mode 100644 components/settings/proposals/components/form.ts create mode 100644 lib/proposals/showRubricAnswersToAuthor.ts diff --git a/apps/scoutgameadmin/components/common/SiteNavigation.tsx b/apps/scoutgameadmin/components/common/SiteNavigation.tsx index ceeb1fe577..ada2be602f 100644 --- a/apps/scoutgameadmin/components/common/SiteNavigation.tsx +++ b/apps/scoutgameadmin/components/common/SiteNavigation.tsx @@ -68,6 +68,8 @@ function getActiveButton(pathname: string) { return 'repos'; } else if (pathname.startsWith('/transactions')) { return 'transactions'; + } else if (pathname.startsWith('/contract')) { + return 'contract'; } else if (pathname.startsWith('/users')) { return 'users'; } diff --git a/components/proposals/ProposalPage/components/ProposalEvaluations/components/Review/components/RubricEvaluation/RubricEvaluation.tsx b/components/proposals/ProposalPage/components/ProposalEvaluations/components/Review/components/RubricEvaluation/RubricEvaluation.tsx index 24b9cacb85..06b14fe99a 100644 --- a/components/proposals/ProposalPage/components/ProposalEvaluations/components/Review/components/RubricEvaluation/RubricEvaluation.tsx +++ b/components/proposals/ProposalPage/components/ProposalEvaluations/components/Review/components/RubricEvaluation/RubricEvaluation.tsx @@ -1,10 +1,12 @@ +import type { ProposalEvaluationType } from '@charmverse/core/prisma-client'; import { Alert } from '@mui/material'; import { useMemo, useState } from 'react'; import MultiTabs from 'components/common/MultiTabs'; import { useIsAdmin } from 'hooks/useIsAdmin'; import { useUser } from 'hooks/useUser'; -import type { ProposalWithUsersAndRubric, PopulatedEvaluation } from 'lib/proposals/interfaces'; +import type { PopulatedEvaluation, ProposalWithUsersAndRubric } from 'lib/proposals/interfaces'; +import { showRubricAnswersToAuthor } from 'lib/proposals/showRubricAnswersToAuthor'; import { RubricAnswersForm } from './components/RubricAnswersForm'; import { RubricDecision } from './components/RubricDecision'; @@ -33,8 +35,18 @@ export function RubricEvaluation({ proposal, isCurrent, evaluation, refreshPropo [user?.id, !!evaluation?.draftRubricAnswers?.length] ); - const canViewRubricAnswers = isAdmin || canAnswerRubric || evaluation.shareReviews; + const isAuthor = proposal.authors.some((a) => a.userId === user?.id); + const authorCanViewFailedEvaluationResults = showRubricAnswersToAuthor({ + isAuthor, + evaluationType: evaluation.type as ProposalEvaluationType, + isCurrentEvaluationStep: isCurrent, + proposalFailed: isCurrent && evaluation.result === 'fail', + showAuthorResultsOnRubricFail: !!evaluation.showAuthorResultsOnRubricFail + }); + + const canViewRubricAnswers = + isAdmin || canAnswerRubric || evaluation.shareReviews || authorCanViewFailedEvaluationResults; async function onSubmitEvaluation({ isDraft }: { isDraft: boolean }) { if (proposal) { await refreshProposal(); @@ -58,7 +70,7 @@ export function RubricEvaluation({ proposal, isCurrent, evaluation, refreshPropo return ( <> - {evaluation.shareReviews + {evaluation.shareReviews || authorCanViewFailedEvaluationResults ? 'Evaluation results are anonymous' : evaluation.isReviewer ? 'Evaluation results are only visible to Reviewers' diff --git a/components/settings/proposals/components/EvaluationAdvancedSettings/EvaluationAdvancedSettings.tsx b/components/settings/proposals/components/EvaluationAdvancedSettings/EvaluationAdvancedSettings.tsx new file mode 100644 index 0000000000..e774d32933 --- /dev/null +++ b/components/settings/proposals/components/EvaluationAdvancedSettings/EvaluationAdvancedSettings.tsx @@ -0,0 +1,52 @@ +import type { WorkflowEvaluationJson } from '@charmverse/core/proposals'; +import { ExpandMore } from '@mui/icons-material'; +import { Accordion, AccordionSummary, Typography, AccordionDetails, Box } from '@mui/material'; +import { Stack } from '@mui/system'; +import { useState } from 'react'; +import type { UseFormSetValue } from 'react-hook-form'; + +import type { EvaluationStepFormValues } from '../form'; + +import { EvaluationAppealSettings } from './EvaluationAppealSettings'; +import { EvaluationRequiredReviews } from './EvaluationRequiredReviews'; +import { ShowRubricResults } from './ShowRubricResults'; +import { StepActionButtonLabel } from './StepActionButtonLabel'; +import { StepFailReasonSelect } from './StepFailReasonSelect'; + +export function EvaluationAdvancedSettingsAccordion({ + formValues, + setValue +}: { + formValues: EvaluationStepFormValues; + setValue: UseFormSetValue; +}) { + const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = useState(false); + const actionLabels = formValues?.actionLabels as WorkflowEvaluationJson['actionLabels']; + const declineReasons = (formValues?.declineReasons as WorkflowEvaluationJson['declineReasons']) ?? []; + return ( + + setIsAdvancedSettingsOpen(!isAdvancedSettingsOpen)} + > + }> + Advanced settings + + + + + {formValues.type === 'rubric' && } + {formValues.type === 'pass_fail' && ( + <> + + + + + )} + + + + + ); +} diff --git a/components/settings/proposals/components/EvaluationAdvancedSettings/EvaluationAppealSettings.tsx b/components/settings/proposals/components/EvaluationAdvancedSettings/EvaluationAppealSettings.tsx new file mode 100644 index 0000000000..397bc4e78b --- /dev/null +++ b/components/settings/proposals/components/EvaluationAdvancedSettings/EvaluationAppealSettings.tsx @@ -0,0 +1,69 @@ +import { Box, Switch, TextField, Typography } from '@mui/material'; +import { Stack } from '@mui/system'; +import type { UseFormSetValue } from 'react-hook-form'; + +import FieldLabel from 'components/common/form/FieldLabel'; + +import type { EvaluationStepFormValues } from '../form'; + +export function EvaluationAppealSettings({ + setValue, + formValues +}: { + formValues: EvaluationStepFormValues; + setValue: UseFormSetValue; +}) { + const { appealable, appealRequiredReviews, finalStep } = formValues; + return ( + + + Priority Step + + + If this Step passes, the entire proposal passes + + { + const checked = e.target.checked; + setValue('finalStep', checked); + setValue('appealRequiredReviews', null); + setValue('appealable', false); + }} + /> + + + + Appeal + + + Authors can appeal the decision. The results of the appeal are final. + + { + const checked = e.target.checked; + setValue('appealRequiredReviews', !checked ? null : 1); + setValue('finalStep', null); + setValue('appealable', checked); + }} + /> + + + {appealable && ( + + Appeal required reviews + { + setValue('appealRequiredReviews', Math.max(1, Number(e.target.value))); + }} + fullWidth + value={appealRequiredReviews ?? ''} + /> + + )} + + ); +} diff --git a/components/settings/proposals/components/EvaluationAdvancedSettings/EvaluationRequiredReviews.tsx b/components/settings/proposals/components/EvaluationAdvancedSettings/EvaluationRequiredReviews.tsx new file mode 100644 index 0000000000..34c4e09596 --- /dev/null +++ b/components/settings/proposals/components/EvaluationAdvancedSettings/EvaluationRequiredReviews.tsx @@ -0,0 +1,29 @@ +import type { WorkflowEvaluationJson } from '@charmverse/core/proposals'; +import { TextField, Box } from '@mui/material'; +import type { UseFormSetValue } from 'react-hook-form'; + +import FieldLabel from 'components/common/form/FieldLabel'; + +import type { EvaluationStepFormValues } from '../form'; + +export function EvaluationRequiredReviews({ + setValue, + requiredReviews +}: { + requiredReviews: WorkflowEvaluationJson['requiredReviews']; + setValue: UseFormSetValue; +}) { + return ( + + Required reviews + { + setValue('requiredReviews', Math.max(1, Number(e.target.value))); + }} + fullWidth + value={requiredReviews} + /> + + ); +} diff --git a/components/settings/proposals/components/EvaluationAdvancedSettings/ShowRubricResults.tsx b/components/settings/proposals/components/EvaluationAdvancedSettings/ShowRubricResults.tsx new file mode 100644 index 0000000000..8bfd800387 --- /dev/null +++ b/components/settings/proposals/components/EvaluationAdvancedSettings/ShowRubricResults.tsx @@ -0,0 +1,29 @@ +import { Box, Stack, Switch, Typography } from '@mui/material'; +import type { UseFormSetValue } from 'react-hook-form'; + +import FieldLabel from 'components/common/form/FieldLabel'; + +import type { EvaluationStepFormValues } from '../form'; + +export function ShowRubricResults({ + setValue, + formValues +}: { + formValues: EvaluationStepFormValues; + setValue: UseFormSetValue; +}) { + return ( + + Show Author Results on Rubric Fail + + setValue('showAuthorResultsOnRubricFail', ev.target.checked)} + /> + + If enabled, authors can see their evaluation results when the evaluation fails + + + + ); +} diff --git a/components/settings/proposals/components/EvaluationAdvancedSettings/StepActionButtonLabel.tsx b/components/settings/proposals/components/EvaluationAdvancedSettings/StepActionButtonLabel.tsx new file mode 100644 index 0000000000..2a725829c1 --- /dev/null +++ b/components/settings/proposals/components/EvaluationAdvancedSettings/StepActionButtonLabel.tsx @@ -0,0 +1,50 @@ +import type { ProposalEvaluationType } from '@charmverse/core/prisma-client'; +import type { WorkflowEvaluationJson } from '@charmverse/core/proposals'; +import { TextField, Box } from '@mui/material'; +import { customLabelEvaluationTypes } from '@root/lib/proposals/getActionButtonLabels'; +import type { UseFormSetValue } from 'react-hook-form'; + +import FieldLabel from 'components/common/form/FieldLabel'; + +import type { EvaluationStepFormValues } from '../form'; + +export function StepActionButtonLabel({ + type, + setValue, + actionLabels +}: { + type: ProposalEvaluationType; + actionLabels: WorkflowEvaluationJson['actionLabels']; + setValue: UseFormSetValue; +}) { + return customLabelEvaluationTypes.includes(type) ? ( + + Decision Labels + { + setValue('actionLabels', { + ...actionLabels, + approve: e.target.value + }); + }} + fullWidth + value={actionLabels?.approve} + sx={{ + mb: 1 + }} + /> + { + setValue('actionLabels', { + ...actionLabels, + reject: e.target.value + }); + }} + fullWidth + value={actionLabels?.reject} + /> + + ) : null; +} diff --git a/components/settings/proposals/components/EvaluationAdvancedSettings/StepFailReasonSelect.tsx b/components/settings/proposals/components/EvaluationAdvancedSettings/StepFailReasonSelect.tsx new file mode 100644 index 0000000000..47f2939205 --- /dev/null +++ b/components/settings/proposals/components/EvaluationAdvancedSettings/StepFailReasonSelect.tsx @@ -0,0 +1,79 @@ +import DeleteIcon from '@mui/icons-material/DeleteOutlineOutlined'; +import { TextField, Typography, IconButton, Box } from '@mui/material'; +import { Stack } from '@mui/system'; +import { useState } from 'react'; +import type { UseFormSetValue } from 'react-hook-form'; + +import { Button } from 'components/common/Button'; +import FieldLabel from 'components/common/form/FieldLabel'; + +import type { EvaluationStepFormValues } from '../form'; + +export function StepFailReasonSelect({ + setValue, + declineReasons +}: { + declineReasons: string[]; + setValue: UseFormSetValue; +}) { + const [declineReason, setDeclineReason] = useState(''); + const isDuplicate = declineReasons.includes(declineReason); + + function addDeclineReason() { + setValue('declineReasons', [...declineReasons, declineReason.trim()]); + setDeclineReason(''); + } + + return ( + + Decline reasons + + { + setDeclineReason(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + addDeclineReason(); + } + }} + /> + + + + {declineReasons.length === 0 && ( + + No decline reasons added + + )} + {declineReasons.map((reason) => ( + + {reason} + { + setValue( + 'declineReasons', + declineReasons.filter((_reason) => reason !== _reason) + ); + }} + > + + + + ))} + + + ); +} diff --git a/components/settings/proposals/components/EvaluationDialog.tsx b/components/settings/proposals/components/EvaluationDialog.tsx index 9bab186c14..a0da42f994 100644 --- a/components/settings/proposals/components/EvaluationDialog.tsx +++ b/components/settings/proposals/components/EvaluationDialog.tsx @@ -1,37 +1,19 @@ -import type { ProposalEvaluationType, ProposalOperation, ProposalSystemRole } from '@charmverse/core/prisma'; import type { WorkflowEvaluationJson } from '@charmverse/core/proposals'; import styled from '@emotion/styled'; -import { ExpandMore } from '@mui/icons-material'; -import DeleteIcon from '@mui/icons-material/DeleteOutlineOutlined'; -import { - Accordion, - AccordionDetails, - AccordionSummary, - Box, - IconButton, - ListItemIcon, - ListItemText, - MenuItem, - Select, - Stack, - Switch, - TextField, - Typography -} from '@mui/material'; -import { useEffect, useState } from 'react'; -import type { UseFormSetValue } from 'react-hook-form'; +import { Box, ListItemIcon, ListItemText, MenuItem, Select, Stack, TextField } from '@mui/material'; +import { useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { v4 as uuid } from 'uuid'; import { Button } from 'components/common/Button'; import { Dialog } from 'components/common/Dialog/Dialog'; import FieldLabel from 'components/common/form/FieldLabel'; -import { useSpaceFeatures } from 'hooks/useSpaceFeatures'; -import { customLabelEvaluationTypes } from 'lib/proposals/getActionButtonLabels'; import { evaluationIcons } from '../constants'; +import { EvaluationAdvancedSettingsAccordion } from './EvaluationAdvancedSettings/EvaluationAdvancedSettings'; import { EvaluationPermissions } from './EvaluationPermissions'; +import type { EvaluationStepFormValues } from './form'; const StyledListItemText = styled(ListItemText)` display: flex; @@ -44,263 +26,6 @@ const StyledListItemText = styled(ListItemText)` // This type is used for existing and new workflows (id is null until it is saved) export type EvaluationTemplateFormItem = Omit & { id: string | null }; -type FormValues = { - id: string; - title: string; - type: ProposalEvaluationType; - actionLabels?: { - approve?: string; - reject?: string; - } | null; - notificationLabels?: { - approve?: string; - reject?: string; - } | null; - requiredReviews?: number; - declineReasons?: string[] | null; - finalStep?: boolean | null; - permissions: { - operation: ProposalOperation; - userId?: string | null; - roleId?: string | null; - systemRole?: ProposalSystemRole | null; - }[]; - appealable?: boolean | null; - appealRequiredReviews?: number | null; -}; - -function StepActionButtonLabel({ - type, - setValue, - actionLabels -}: { - type: ProposalEvaluationType; - actionLabels: WorkflowEvaluationJson['actionLabels']; - setValue: UseFormSetValue; -}) { - return customLabelEvaluationTypes.includes(type) ? ( - - Decision Labels - { - setValue('actionLabels', { - ...actionLabels, - approve: e.target.value - }); - }} - fullWidth - value={actionLabels?.approve} - sx={{ - mb: 1 - }} - /> - { - setValue('actionLabels', { - ...actionLabels, - reject: e.target.value - }); - }} - fullWidth - value={actionLabels?.reject} - /> - - ) : null; -} - -function StepFailReasonSelect({ - setValue, - declineReasons -}: { - declineReasons: string[]; - setValue: UseFormSetValue; -}) { - const [declineReason, setDeclineReason] = useState(''); - const isDuplicate = declineReasons.includes(declineReason); - - function addDeclineReason() { - setValue('declineReasons', [...declineReasons, declineReason.trim()]); - setDeclineReason(''); - } - - return ( - - Decline reasons - - { - setDeclineReason(e.target.value); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - addDeclineReason(); - } - }} - /> - - - - {declineReasons.length === 0 && ( - - No decline reasons added - - )} - {declineReasons.map((reason) => ( - - {reason} - { - setValue( - 'declineReasons', - declineReasons.filter((_reason) => reason !== _reason) - ); - }} - > - - - - ))} - - - ); -} - -function StepRequiredReviews({ - setValue, - requiredReviews -}: { - requiredReviews: WorkflowEvaluationJson['requiredReviews']; - setValue: UseFormSetValue; -}) { - return ( - - Required reviews - { - setValue('requiredReviews', Math.max(1, Number(e.target.value))); - }} - fullWidth - value={requiredReviews} - /> - - ); -} - -function EvaluationAppealSettings({ - setValue, - formValues -}: { - formValues: FormValues; - setValue: UseFormSetValue; -}) { - const { appealable, appealRequiredReviews, finalStep } = formValues; - const { getFeatureTitle } = useSpaceFeatures(); - return ( - - - Priority Step - - - If this Step passes, the entire proposal passes - - { - const checked = e.target.checked; - setValue('finalStep', checked); - setValue('appealRequiredReviews', null); - setValue('appealable', false); - }} - /> - - - - Appeal - - - Authors can appeal the decision. The results of the appeal are final. - - { - const checked = e.target.checked; - setValue('appealRequiredReviews', !checked ? null : 1); - setValue('finalStep', null); - setValue('appealable', checked); - }} - /> - - - {appealable && ( - - Appeal required reviews - { - setValue('appealRequiredReviews', Math.max(1, Number(e.target.value))); - }} - fullWidth - value={appealRequiredReviews ?? ''} - /> - - )} - - ); -} - -function EvaluationAdvancedSettingsAccordion({ - formValues, - setValue -}: { - formValues: FormValues; - setValue: UseFormSetValue; -}) { - const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = useState(false); - const actionLabels = formValues?.actionLabels as WorkflowEvaluationJson['actionLabels']; - const declineReasons = (formValues?.declineReasons as WorkflowEvaluationJson['declineReasons']) ?? []; - return ( - - setIsAdvancedSettingsOpen(!isAdvancedSettingsOpen)} - > - }> - Advanced settings - - - - - {formValues.type === 'pass_fail' && ( - <> - - - - - )} - - - - - ); -} - export function EvaluationDialog({ evaluation, isFirstEvaluation, @@ -319,7 +44,7 @@ export function EvaluationDialog({ setValue, watch, formState: { isValid } - } = useForm({}); + } = useForm({}); const dialogTitle = evaluation?.id ? 'Edit evaluation' : evaluation ? 'New evaluation step' : ''; @@ -342,11 +67,12 @@ export function EvaluationDialog({ declineReasons: evaluation?.declineReasons ?? [], finalStep: evaluation?.finalStep ?? false, appealable: evaluation?.appealable ?? false, - appealRequiredReviews: evaluation?.appealRequiredReviews + appealRequiredReviews: evaluation?.appealRequiredReviews, + showAuthorResultsOnRubricFail: evaluation?.showAuthorResultsOnRubricFail }); }, [evaluation?.id]); - async function saveForm(values: FormValues) { + async function saveForm(values: EvaluationStepFormValues) { await onSave({ ...evaluation, ...values, diff --git a/components/settings/proposals/components/form.ts b/components/settings/proposals/components/form.ts new file mode 100644 index 0000000000..bc27e56d2b --- /dev/null +++ b/components/settings/proposals/components/form.ts @@ -0,0 +1,27 @@ +import type { ProposalEvaluationType, ProposalOperation, ProposalSystemRole } from '@charmverse/core/prisma-client'; + +export type EvaluationStepFormValues = { + id: string; + title: string; + type: ProposalEvaluationType; + actionLabels?: { + approve?: string; + reject?: string; + } | null; + notificationLabels?: { + approve?: string; + reject?: string; + } | null; + requiredReviews?: number; + declineReasons?: string[] | null; + finalStep?: boolean | null; + permissions: { + operation: ProposalOperation; + userId?: string | null; + roleId?: string | null; + systemRole?: ProposalSystemRole | null; + }[]; + appealable?: boolean | null; + appealRequiredReviews?: number | null; + showAuthorResultsOnRubricFail?: boolean | null; +}; diff --git a/lib/proposals/__tests__/applyProposalTemplate.spec.ts b/lib/proposals/__tests__/applyProposalTemplate.spec.ts index f4c63c31cb..13b5f1f8f2 100644 --- a/lib/proposals/__tests__/applyProposalTemplate.spec.ts +++ b/lib/proposals/__tests__/applyProposalTemplate.spec.ts @@ -79,7 +79,8 @@ describe('applyProposalTemplate', () => { { title: 'Test Criteria 1', description: 'Description 1', parameters: { type: 'score', min: 0, max: 20 } } ], permissions: [{ assignee: { group: 'current_reviewer' }, operation: 'move' }], - reviewers: [{ group: 'user', id: user.id }] + reviewers: [{ group: 'user', id: user.id }], + showAuthorResultsOnRubricFail: true }, { evaluationType: 'vote', @@ -140,6 +141,7 @@ describe('applyProposalTemplate', () => { userId: template.evaluations[2].reviewers[0].userId }) ], + showAuthorResultsOnRubricFail: true, rubricCriteria: [ expect.objectContaining({ title: template.evaluations[2].rubricCriteria[0].title, diff --git a/lib/proposals/__tests__/concealProposalSteps.spec.ts b/lib/proposals/__tests__/concealProposalSteps.spec.ts index df3ca16e2a..6f112f2fa8 100644 --- a/lib/proposals/__tests__/concealProposalSteps.spec.ts +++ b/lib/proposals/__tests__/concealProposalSteps.spec.ts @@ -41,6 +41,7 @@ describe('concealProposalSteps', () => { workflowId: privateProposalWorkflow.id, id: proposalId, workflow: { privateEvaluations: true }, + authors: [{ userId: author.id }], evaluations: [ { id: uuid(), @@ -128,6 +129,67 @@ describe('concealProposalSteps', () => { expect(result).toEqual(proposalWithSteps); }); + it('should return the step where the proposal failed despite being hidden, if the step is configured to show rubric results on fail, and the user is the author', async () => { + const proposalWithShowUserResultsOnFail: MinimalProposal = { + ...proposalWithSteps, + evaluations: [ + { + id: uuid(), + type: 'feedback', + result: null, + index: 0, + reviewers: [ + { evaluationId: '', id: '', proposalId: '', roleId: null, systemRole: null, userId: reviewerUser.id } + ] + }, + { + id: uuid(), + type: 'rubric', + result: 'fail', + index: 1, + reviewers: [ + { evaluationId: '', id: '', proposalId: '', roleId: null, systemRole: null, userId: reviewerUser.id } + ], + showAuthorResultsOnRubricFail: true + }, + { + id: uuid(), + type: 'pass_fail', + result: null, + index: 2, + reviewers: [ + { evaluationId: '', id: '', proposalId: '', roleId: null, systemRole: null, userId: reviewerUser.id } + ], + appealReviewers: [ + { + userId: appealReviewerUser.id, + roleId: null, + id: '', + proposalId: '', + evaluationId: '' + } + ] + } + ] + }; + + const result = await concealProposalSteps({ + proposal: proposalWithShowUserResultsOnFail, + userId: author.id + }); + expect(result.evaluations).toEqual([ + { ...proposalWithShowUserResultsOnFail.evaluations[0] }, + { ...proposalWithShowUserResultsOnFail.evaluations[1] }, + expect.objectContaining({ + ...proposalWithShowUserResultsOnFail.evaluations[2], + type: 'private_evaluation', + title: 'Evaluation', + reviewers: [], + permissions: [] + }) + ]); + }); + it('should correctly conceal and collapse evaluations', async () => { const result = await concealProposalSteps({ proposal: { ...proposalWithSteps }, @@ -150,6 +212,7 @@ describe('concealProposalSteps', () => { spaceId: space.id, workflowId: privateProposalWorkflow.id, id: proposalId, + authors: [{ userId: author.id }], evaluations: [ { id: uuid(), diff --git a/lib/proposals/applyProposalTemplate.ts b/lib/proposals/applyProposalTemplate.ts index 1e4567cc0d..d2e9ded927 100644 --- a/lib/proposals/applyProposalTemplate.ts +++ b/lib/proposals/applyProposalTemplate.ts @@ -173,7 +173,8 @@ export async function applyProposalTemplate({ data: { requiredReviews: evaluation.requiredReviews, actionLabels: evaluation.actionLabels as any, - voteSettings: evaluation.voteSettings as any + voteSettings: evaluation.voteSettings as any, + showAuthorResultsOnRubricFail: evaluation.showAuthorResultsOnRubricFail } }); }) diff --git a/lib/proposals/applyProposalWorkflow.ts b/lib/proposals/applyProposalWorkflow.ts index 2950187f6d..3a0b9377ee 100644 --- a/lib/proposals/applyProposalWorkflow.ts +++ b/lib/proposals/applyProposalWorkflow.ts @@ -98,7 +98,8 @@ export async function applyProposalWorkflow({ appealRequiredReviews, notificationLabels: evaluation.notificationLabels || undefined, actionLabels: evaluation.actionLabels || undefined, - finalStep: evaluation.finalStep + finalStep: evaluation.finalStep, + showAuthorResultsOnRubricFail: evaluation.showAuthorResultsOnRubricFail } }); } diff --git a/lib/proposals/concealProposalSteps.ts b/lib/proposals/concealProposalSteps.ts index 237b6dde13..ed82c46cb2 100644 --- a/lib/proposals/concealProposalSteps.ts +++ b/lib/proposals/concealProposalSteps.ts @@ -1,5 +1,10 @@ import { hasAccessToSpace } from '@charmverse/core/permissions'; -import type { ProposalAppealReviewer, ProposalEvaluationType, ProposalReviewer } from '@charmverse/core/prisma-client'; +import type { + ProposalAppealReviewer, + ProposalAuthor, + ProposalEvaluationType, + ProposalReviewer +} from '@charmverse/core/prisma-client'; import { prisma } from '@charmverse/core/prisma-client'; import { privateEvaluationSteps } from '@charmverse/core/proposals'; import { getAssignedRoleIds } from '@root/lib/roles/getAssignedRoleIds'; @@ -13,6 +18,7 @@ export type MinimalProposal = Pick & Partial)[]; + authors: Pick[]; }; export async function concealProposalSteps({ @@ -88,12 +94,20 @@ export async function concealProposalSteps author.userId === userId); + for (let i = 0; i < proposal.evaluations.length; i++) { const previousStep = stepsWithCollapsedEvaluations[stepsWithCollapsedEvaluations.length - 1]; const currentStep = proposal.evaluations[i]; const isConcealableEvaluation = privateEvaluationSteps.includes(currentStep.type as ProposalEvaluationType); - if (!isConcealableEvaluation) { + if ( + !isConcealableEvaluation || + (isAuthor && + currentStep.type === 'rubric' && + currentStep.result === 'fail' && + currentStep.showAuthorResultsOnRubricFail) + ) { stepsWithCollapsedEvaluations.push(currentStep); } else if (previousStep?.type !== 'private_evaluation') { stepsWithCollapsedEvaluations.push({ diff --git a/lib/proposals/createProposal.ts b/lib/proposals/createProposal.ts index 7b6331f908..dd893da9b6 100644 --- a/lib/proposals/createProposal.ts +++ b/lib/proposals/createProposal.ts @@ -50,6 +50,7 @@ export type ProposalEvaluationInput = Pick>[] | null; shareReviews?: boolean | null; dueDate?: Date | null; + showAuthorResultsOnRubricFail?: boolean | null; }; export type CreateProposalInput = { @@ -221,7 +222,8 @@ export async function createProposal({ appealable: evaluation.appealable, appealRequiredReviews: evaluation.appealRequiredReviews, shareReviews: evaluation.shareReviews, - dueDate: evaluation.dueDate + dueDate: evaluation.dueDate, + showAuthorResultsOnRubricFail: evaluation.showAuthorResultsOnRubricFail })) } }, diff --git a/lib/proposals/getProposal.ts b/lib/proposals/getProposal.ts index 45256fc38c..31ad4599d7 100644 --- a/lib/proposals/getProposal.ts +++ b/lib/proposals/getProposal.ts @@ -15,10 +15,12 @@ type PermissionsMap = Awaited< export async function getProposal({ id, - permissionsByStep + permissionsByStep, + userId }: { id: string; permissionsByStep: PermissionsMap; + userId?: string; }): Promise { const proposal = await prisma.proposal.findUniqueOrThrow({ where: { @@ -117,6 +119,7 @@ export async function getProposal({ proposal: { ...proposal, issuedCredentials: credentials }, permissions: currentPermissions, isPublicPage, - permissionsByStep + permissionsByStep, + userId }); } diff --git a/lib/proposals/mapDbProposalToProposal.ts b/lib/proposals/mapDbProposalToProposal.ts index 6b3654938c..19af2407e4 100644 --- a/lib/proposals/mapDbProposalToProposal.ts +++ b/lib/proposals/mapDbProposalToProposal.ts @@ -4,15 +4,17 @@ import type { Page, Proposal, ProposalAuthor, + ProposalEvaluationType, ProposalReviewer, ProposalRubricCriteria, ProposalRubricCriteriaAnswer } from '@charmverse/core/prisma'; -import type { - ProposalAppealReviewer, - ProposalEvaluation, - ProposalEvaluationAppealReview, - ProposalEvaluationReview +import { + ProposalEvaluationResult, + type ProposalAppealReviewer, + type ProposalEvaluation, + type ProposalEvaluationAppealReview, + type ProposalEvaluationReview } from '@charmverse/core/prisma-client'; import type { WorkflowEvaluationJson } from '@charmverse/core/proposals'; import { getCurrentEvaluation } from '@charmverse/core/proposals'; @@ -25,6 +27,7 @@ import { getProposalFormFields } from '@root/lib/proposals/form/getProposalFormF import { getProposalProjectFormAnswers } from './form/getProposalProjectFormAnswers'; import type { PopulatedEvaluation, ProposalFields, ProposalWithUsersAndRubric, TypedFormField } from './interfaces'; +import { showRubricAnswersToAuthor } from './showRubricAnswersToAuthor'; type FormFieldsIncludeType = { form: { @@ -40,7 +43,8 @@ export function mapDbProposalToProposal({ proposalEvaluationReviews, workflow, proposalEvaluationAppealReviews, - isPublicPage + isPublicPage, + userId }: { workflow: { evaluations: WorkflowEvaluationJson[]; @@ -68,6 +72,7 @@ export function mapDbProposalToProposal({ }; permissions: ProposalPermissionFlags; permissionsByStep?: Record; + userId?: string; }): ProposalWithUsersAndRubric { const { rewards, form, evaluations, fields, page, issuedCredentials, ...rest } = proposal; const currentEvaluation = getCurrentEvaluation(proposal.evaluations); @@ -85,6 +90,8 @@ export function mapDbProposalToProposal({ }) : null; + const isAuthor = !!userId && proposal.authors.some((a) => a.userId === userId); + const mappedEvaluations = proposal.evaluations.map((evaluation) => { const workflowEvaluation = workflow?.evaluations.find( (e) => e.title === evaluation.title && e.type === evaluation.type @@ -102,9 +109,18 @@ export function mapDbProposalToProposal({ arrayUtils.uniqueValues(evaluation.rubricAnswers.map((a) => a.userId)).length : evaluation.reviews.length; + const proposalFailed = currentEvaluation?.result === ProposalEvaluationResult.fail; + if (!stepPermissions?.evaluate) { + const showReviewsToAuthor = showRubricAnswersToAuthor({ + evaluationType: evaluation.type as ProposalEvaluationType, + isAuthor, + proposalFailed: !!proposalFailed, + isCurrentEvaluationStep: evaluation.id === currentEvaluation?.id, + showAuthorResultsOnRubricFail: !!evaluation.showAuthorResultsOnRubricFail + }); draftRubricAnswers = []; - if (!evaluation.shareReviews) { + if (!evaluation.shareReviews && !showReviewsToAuthor) { rubricAnswers = []; reviews = []; } diff --git a/lib/proposals/showRubricAnswersToAuthor.ts b/lib/proposals/showRubricAnswersToAuthor.ts new file mode 100644 index 0000000000..5ec5517321 --- /dev/null +++ b/lib/proposals/showRubricAnswersToAuthor.ts @@ -0,0 +1,23 @@ +import type { ProposalEvaluationType } from '@charmverse/core/prisma-client'; + +export function showRubricAnswersToAuthor({ + isAuthor, + proposalFailed, + isCurrentEvaluationStep, + evaluationType, + showAuthorResultsOnRubricFail +}: { + isAuthor: boolean; + proposalFailed: boolean; + isCurrentEvaluationStep: boolean; + evaluationType: ProposalEvaluationType; + showAuthorResultsOnRubricFail: boolean; +}) { + return ( + isAuthor && + proposalFailed && + isCurrentEvaluationStep && + evaluationType === 'rubric' && + showAuthorResultsOnRubricFail + ); +} diff --git a/lib/proposals/workflows/upsertWorkflowTemplate.ts b/lib/proposals/workflows/upsertWorkflowTemplate.ts index 8f8f93edd4..8a20b93fc6 100644 --- a/lib/proposals/workflows/upsertWorkflowTemplate.ts +++ b/lib/proposals/workflows/upsertWorkflowTemplate.ts @@ -65,7 +65,8 @@ export async function upsertWorkflowTemplate(workflow: ProposalWorkflowTyped) { appealable: workflowEvaluation.appealable, appealRequiredReviews: workflowEvaluation.appealRequiredReviews, finalStep: workflowEvaluation.finalStep, - requiredReviews: workflowEvaluation.requiredReviews || 1 + requiredReviews: workflowEvaluation.requiredReviews || 1, + showAuthorResultsOnRubricFail: workflowEvaluation.showAuthorResultsOnRubricFail } }); processedIds.push(originalTemplateEvaluation.id); @@ -81,7 +82,8 @@ export async function upsertWorkflowTemplate(workflow: ProposalWorkflowTyped) { appealable: workflowEvaluation.appealable, appealRequiredReviews: workflowEvaluation.appealRequiredReviews, finalStep: workflowEvaluation.finalStep, - requiredReviews: workflowEvaluation.requiredReviews || 1 + requiredReviews: workflowEvaluation.requiredReviews || 1, + showAuthorResultsOnRubricFail: workflowEvaluation.showAuthorResultsOnRubricFail } }); } diff --git a/pages/api/proposals/[id]/index.ts b/pages/api/proposals/[id]/index.ts index 59b8de044a..5621e4f041 100644 --- a/pages/api/proposals/[id]/index.ts +++ b/pages/api/proposals/[id]/index.ts @@ -27,7 +27,7 @@ async function getProposalController(req: NextApiRequest, res: NextApiResponse