From d077c97ab850b159a2215eb142b08297a4f7b1e4 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 21 Aug 2024 16:30:38 -0700 Subject: [PATCH 01/58] feat: add team question response evaluation id validation --- src/back-end/lib/validation.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/back-end/lib/validation.ts b/src/back-end/lib/validation.ts index dbad9cd08..fc0cfce91 100644 --- a/src/back-end/lib/validation.ts +++ b/src/back-end/lib/validation.ts @@ -80,6 +80,7 @@ import { validateSWUEvaluationPanelMemberChair, validateSWUEvaluationPanelMemberEvaluator } from "shared/lib/validation/opportunity/sprint-with-us"; +import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; /** * TWU - Team With Us Validation @@ -1014,6 +1015,38 @@ export async function validateDraftProposalOrganization( ); } +export async function validateSWUTeamQuestionResponseEvaluationId( + connection: db.Connection, + evaluationId: Id, + session: AuthenticatedSession +): Promise> { + try { + const validatedId = validateUUID(evaluationId); + if (isInvalid(validatedId)) { + return validatedId; + } + const dbResult = await db.readOneSWUTeamQuestionResponseEvaluation( + connection, + evaluationId, + session + ); + if (isInvalid(dbResult)) { + return invalid([db.ERROR_MESSAGE]); + } + const evaluation = dbResult.value; + if (!evaluation) { + return invalid([ + "The specified team question response evaluation was not found." + ]); + } + return valid(evaluation); + } catch (exception) { + return invalid([ + "Please select a valid team question response evaluation." + ]); + } +} + export async function validateContentId( connection: db.Connection, contentId: Id, From 57b9f01dd8f71565d612edb324ee5580677b2445 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Mon, 26 Aug 2024 16:48:33 -0700 Subject: [PATCH 02/58] feat: edit swu team question evaluation permission --- src/back-end/lib/permissions.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/back-end/lib/permissions.ts b/src/back-end/lib/permissions.ts index 581e5b878..d0a6ecf0b 100644 --- a/src/back-end/lib/permissions.ts +++ b/src/back-end/lib/permissions.ts @@ -67,6 +67,10 @@ import { isSWUOpportunityEvaluationPanelChair, isSWUOpportunityEvaluationPanelEvaluator } from "./db/question-evaluation/sprint-with-us"; +import { + SWUTeamQuestionResponseEvaluation, + SWUTeamQuestionResponseEvaluationType +} from "shared/lib/resources/question-evaluation/sprint-with-us"; export const ERROR_MESSAGE = "You do not have permission to perform this action."; @@ -894,6 +898,23 @@ export async function createSWUTeamQuestionResponseEvaluation( ); } +export function editSWUTeamQuestionResponseEvaluation( + session: Session, + evaluation: SWUTeamQuestionResponseEvaluation +): boolean { + return ( + !!session && + (isAdmin(session) || isGovernment(session)) && + evaluation.evaluationPanelMember.user.id === session.user.id && + ((evaluation.proposal.opportunity.status === + SWUOpportunityStatus.TeamQuestionsPanelEvaluation && + evaluation.type === SWUTeamQuestionResponseEvaluationType.Individual) || + (evaluation.proposal.opportunity.status === + SWUOpportunityStatus.TeamQuestionsPanelConsensus && + evaluation.type === SWUTeamQuestionResponseEvaluationType.Conensus)) + ); +} + // TWU Opportunities export function createTWUOpportunity( From 1b55c49d0475057419e0dfc8d69da3ce5c0673ab Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 28 Aug 2024 10:13:09 -0700 Subject: [PATCH 03/58] feat: add update score helper --- .../db/question-evaluation/sprint-with-us.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts index 63de4ebd8..e81bfcafa 100644 --- a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts @@ -3,7 +3,12 @@ import { readOneSWUOpportunity } from "back-end/lib/db/opportunity/sprint-with-us"; import { getValidValue, isInvalid, valid } from "shared/lib/validation"; -import { Connection, readOneSWUProposal, tryDb } from "back-end/lib/db"; +import { + Connection, + Transaction, + readOneSWUProposal, + tryDb +} from "back-end/lib/db"; import { AuthenticatedSession, Session, @@ -13,6 +18,7 @@ import { Id } from "shared/lib/types"; import { SWUEvaluationPanelMember } from "shared/lib/resources/opportunity/sprint-with-us"; import { CreateRequestBody, + CreateSWUTeamQuestionResponseEvaluationScoreBody, SWUTeamQuestionResponseEvaluation, SWUTeamQuestionResponseEvaluationScores, SWUTeamQuestionResponseEvaluationStatus @@ -247,6 +253,29 @@ export const createSWUTeamQuestionResponseEvaluation = tryDb< return valid(dbResult.value); }); +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function updateSWUTeamQuestionResponseEvaluationScores( + connection: Transaction, + evaluationId: Id, + scores: CreateSWUTeamQuestionResponseEvaluationScoreBody[] +): Promise { + // Remove existing and recreate + await connection("swuTeamQuestionResponseEvaluationScores") + .where({ teamQuestionResponseEvaluation: evaluationId }) + .delete(); + + for (const score of scores) { + await connection< + SWUTeamQuestionResponseEvaluationScores & { + teamQuestionResponseEvaluation: Id; + } + >("swuTeamQuestionResponseEvaluationScores").insert({ + ...score, + teamQuestionResponseEvaluation: evaluationId + }); + } +} + function generateSWUTeamQuestionResponseEvaluationQuery( connection: Connection ) { From dab3b8f0fade00af4e6252815895d916abe92a48 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 28 Aug 2024 10:14:28 -0700 Subject: [PATCH 04/58] fix: use correct resource wording for error --- src/back-end/lib/db/question-evaluation/sprint-with-us.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts index e81bfcafa..9ff34afa9 100644 --- a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts @@ -248,7 +248,7 @@ export const createSWUTeamQuestionResponseEvaluation = tryDb< session ); if (isInvalid(dbResult) || !dbResult.value) { - throw new Error("unable to create proposal"); + throw new Error("unable to create team question evaluation"); } return valid(dbResult.value); }); From 9679314a8529ccb6362f6db4a65d61df3fab8c83 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 28 Aug 2024 10:16:16 -0700 Subject: [PATCH 05/58] feat: update team question response evaluation db function --- .../db/question-evaluation/sprint-with-us.ts | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts index 9ff34afa9..64a3871ea 100644 --- a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts @@ -21,7 +21,8 @@ import { CreateSWUTeamQuestionResponseEvaluationScoreBody, SWUTeamQuestionResponseEvaluation, SWUTeamQuestionResponseEvaluationScores, - SWUTeamQuestionResponseEvaluationStatus + SWUTeamQuestionResponseEvaluationStatus, + UpdateEditRequestBody } from "shared/lib/resources/question-evaluation/sprint-with-us"; import { generateUuid } from "back-end/lib"; @@ -30,6 +31,11 @@ export interface CreateSWUTeamQuestionResponseEvaluationParams evaluationPanelMember: Id; } +interface UpdateSWUTeamQuestionResponseEvaluationParams + extends UpdateEditRequestBody { + id: Id; +} + interface SWUTeamQuestionResponseEvaluationStatusRecord { id: Id; opportunity: Id; @@ -253,7 +259,50 @@ export const createSWUTeamQuestionResponseEvaluation = tryDb< return valid(dbResult.value); }); -// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const updateSWUTeamQuestionResponseEvaluation = tryDb< + [UpdateSWUTeamQuestionResponseEvaluationParams, AuthenticatedSession], + SWUTeamQuestionResponseEvaluation +>(async (connection, proposal, session) => { + const now = new Date(); + const { id, scores } = proposal; + return valid( + await connection.transaction(async (trx) => { + // Update organization/timestamps + const [result] = await connection( + "swuTeamQuestionResponseEvaluations" + ) + .transacting(trx) + .where({ id }) + .update( + { + updatedAt: now + }, + "*" + ); + + if (!result) { + throw new Error("unable to update team question evaluation"); + } + // Update scores + await updateSWUTeamQuestionResponseEvaluationScores( + trx, + result.id, + scores + ); + + const dbResult = await readOneSWUTeamQuestionResponseEvaluation( + trx, + result.id, + session + ); + if (isInvalid(dbResult) || !dbResult.value) { + throw new Error("unable to update team question evaluation"); + } + return dbResult.value; + }) + ); +}); + async function updateSWUTeamQuestionResponseEvaluationScores( connection: Transaction, evaluationId: Id, From 0942bcd2414f6f6be5cde59bfb5cd2f881c24942 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 28 Aug 2024 10:18:45 -0700 Subject: [PATCH 06/58] fix: reflect correct updates in comment --- .../question-evaluation/sprint-with-us.ts | 243 +++++++++++++++++- 1 file changed, 239 insertions(+), 4 deletions(-) diff --git a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts index ef55b1f3a..fbb40bba4 100644 --- a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts @@ -7,19 +7,27 @@ import { makeJsonResponseBody, wrapRespond } from "back-end/lib/server"; -import { validateSWUProposalId } from "back-end/lib/validation"; +import { + validateSWUProposalId, + validateSWUTeamQuestionResponseEvaluationId +} from "back-end/lib/validation"; import { get, omit } from "lodash"; import { getString } from "shared/lib"; import { + CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors, CreateValidationErrors, SWUTeamQuestionResponseEvaluation, SWUTeamQuestionResponseEvaluationStatus, SWUTeamQuestionResponseEvaluationType, - CreateRequestBody as SharedCreateRequestBody + CreateRequestBody as SharedCreateRequestBody, + UpdateRequestBody, + UpdateValidationErrors, + isValidStatusChange } from "shared/lib/resources/question-evaluation/sprint-with-us"; import { AuthenticatedSession, Session } from "shared/lib/resources/session"; -import { Id } from "shared/lib/types"; +import { ADT, Id, adt } from "shared/lib/types"; import { + Validation, getInvalidValue, invalid, isInvalid, @@ -33,6 +41,16 @@ interface ValidatedCreateRequestBody extends SharedCreateRequestBody { evaluationPanelMember: Id; } +interface ValidatedUpdateRequestBody { + session: AuthenticatedSession; + body: ADT<"edit", ValidatedUpdateEditRequestBody> | ADT<"submit", string>; +} + +type ValidatedUpdateEditRequestBody = Omit< + ValidatedCreateRequestBody, + "proposal" | "evaluationPanelMember" | "status" | "type" | "session" +>; + type CreateRequestBody = Omit & { status: string; type: string; @@ -186,9 +204,226 @@ const create: crud.Create< }; }; +const update: crud.Update< + Session, + db.Connection, + UpdateRequestBody, + ValidatedUpdateRequestBody, + UpdateValidationErrors +> = (connection: db.Connection) => { + return { + async parseRequestBody(request) { + const body = request.body.tag === "json" ? request.body.value : {}; + const tag = get(body, "tag"); + const value: unknown = get(body, "value"); + switch (tag) { + case "edit": + return adt("edit", { + scores: get(value, "scores") + }); + case "submit": + return adt("submit", getString(body, "value", "")); + default: + return null; + } + }, + async validateRequestBody(request) { + if (!request.body) { + return invalid({ proposal: adt("parseFailure" as const) }); + } + if (!permissions.isSignedIn(request.session)) { + return invalid({ + permissions: [permissions.ERROR_MESSAGE] + }); + } + const validatedSWUTeamQuestionResponseEvaluation = + await validateSWUTeamQuestionResponseEvaluationId( + connection, + request.params.id, + request.session + ); + if (isInvalid(validatedSWUTeamQuestionResponseEvaluation)) { + return invalid({ + notFound: getInvalidValue( + validatedSWUTeamQuestionResponseEvaluation, + undefined + ) + }); + } + + if ( + !permissions.editSWUTeamQuestionResponseEvaluation( + request.session, + validatedSWUTeamQuestionResponseEvaluation.value + ) + ) { + return invalid({ + permissions: [permissions.ERROR_MESSAGE] + }); + } + + switch (request.body.tag) { + case "edit": { + const scores = request.body.value.scores; + + const fullProposal = await db.readOneSWUProposal( + connection, + validatedSWUTeamQuestionResponseEvaluation.value.proposal.id, + request.session + ); + + const validatedScores = + questionEvaluationValidation.validateSWUTeamQuestionResponseEvaluationScores( + scores, + fullProposal.value?.teamQuestionResponses ?? [] + ); + + if (isValid(validatedScores)) { + return valid( + adt( + "edit" as const, + { + scores: validatedScores.value + } as ValidatedUpdateEditRequestBody + ) + ); + } else { + return invalid({ + evaluation: adt("edit" as const, { + scores: getInvalidValue(validatedScores, undefined) + }) + }); + } + } + case "submit": { + if ( + !isValidStatusChange( + validatedSWUTeamQuestionResponseEvaluation.value.status, + SWUTeamQuestionResponseEvaluationStatus.Submitted + ) + ) { + return invalid({ + permissions: [permissions.ERROR_MESSAGE] + }); + } + + const fullProposal = await db.readOneSWUProposal( + connection, + validatedSWUTeamQuestionResponseEvaluation.value.proposal.id, + request.session + ); + + const validatedScores = + questionEvaluationValidation.validateSWUTeamQuestionResponseEvaluationScores( + validatedSWUTeamQuestionResponseEvaluation.value.scores, + fullProposal.value?.teamQuestionResponses ?? [] + ); + + if ( + isInvalid< + CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors[] + >(validatedScores) || + validatedScores.value.length !== + fullProposal.value?.teamQuestionResponses.length + ) { + return invalid({ + evaluation: adt("submit" as const, [ + "This evaluation could not be submitted for review because it is incomplete. Please edit, complete and save the appropriate form before trying to submit it again." + ]) + }); + } + + if ( + !permissions.submitSWUTeamQuestionResponseEvaluation( + request.session, + validatedSWUTeamQuestionResponseEvaluation.value + ) + ) { + return invalid({ + permissions: [permissions.ERROR_MESSAGE] + }); + } + + const validatedSubmissionNote = + questionEvaluationValidation.validateNote(request.body.value); + if (isInvalid(validatedSubmissionNote)) { + return invalid({ + proposal: adt("submit" as const, validatedSubmissionNote.value) + }); + } + return valid({ + session: request.session, + body: adt("submit" as const, validatedSubmissionNote.value) + } as ValidatedUpdateRequestBody); + } + default: + return invalid({ proposal: adt("parseFailure" as const) }); + } + }, + respond: wrapRespond< + ValidatedUpdateRequestBody, + UpdateValidationErrors, + JsonResponseBody, + JsonResponseBody, + Session + >({ + valid: async (request) => { + let dbResult: Validation; + const { session, body } = request.body; + switch (body.tag) { + case "edit": + dbResult = await db.updateSWUTeamQuestionResponseEvaluation( + connection, + { ...body.value, id: request.params.id }, + session + ); + break; + case "submit": + dbResult = await db.updateSWUProposalStatus( + connection, + request.params.id, + SWUProposalStatus.Submitted, + body.value, + session + ); + // Notify of submission + if (isValid(dbResult)) { + swuProposalNotifications.handleSWUProposalSubmitted( + connection, + request.params.id, + request.body.session + ); + } + break; + } + if (isInvalid(dbResult)) { + return basicResponse( + 503, + request.session, + makeJsonResponseBody({ database: [db.ERROR_MESSAGE] }) + ); + } + return basicResponse( + 200, + request.session, + makeJsonResponseBody(dbResult.value) + ); + }, + invalid: async (request) => { + return basicResponse( + 400, + request.session, + makeJsonResponseBody(request.body) + ); + } + }) + }; +}; + const resource: crud.BasicCrudResource = { routeNamespace, - create + create, + update }; export default resource; From dbc065a00c5d318c86cfc4941df6477e1cc7eac1 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 28 Aug 2024 14:47:23 -0700 Subject: [PATCH 07/58] feat: update team question response evaluation status db function --- .../db/question-evaluation/sprint-with-us.ts | 92 +++++++++++++++---- 1 file changed, 73 insertions(+), 19 deletions(-) diff --git a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts index 64a3871ea..f2bcbafdd 100644 --- a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts @@ -38,7 +38,7 @@ interface UpdateSWUTeamQuestionResponseEvaluationParams interface SWUTeamQuestionResponseEvaluationStatusRecord { id: Id; - opportunity: Id; + teamQuestionResponseEvaluation: Id; createdAt: Date; createdBy: Id; status: SWUTeamQuestionResponseEvaluationStatus; @@ -209,23 +209,22 @@ export const createSWUTeamQuestionResponseEvaluation = tryDb< } // Create a evaluation status record - const [evaluationStatusRecord] = await connection< - SWUTeamQuestionResponseEvaluationStatusRecord & { - teamQuestionResponseEvaluation: Id; - } - >("swuTeamQuestionResponseEvaluationStatuses") - .transacting(trx) - .insert( - { - id: generateUuid(), - teamQuestionResponseEvaluation: evaluationRootRecord.id, - status, - createdAt: now, - createdBy: session.user.id, - note: "" - }, - "*" - ); + const [evaluationStatusRecord] = + await connection( + "swuTeamQuestionResponseEvaluationStatuses" + ) + .transacting(trx) + .insert( + { + id: generateUuid(), + teamQuestionResponseEvaluation: evaluationRootRecord.id, + status, + createdAt: now, + createdBy: session.user.id, + note: "" + }, + "*" + ); if (!evaluationStatusRecord) { throw new Error("unable to create team question evaluation status"); @@ -267,7 +266,7 @@ export const updateSWUTeamQuestionResponseEvaluation = tryDb< const { id, scores } = proposal; return valid( await connection.transaction(async (trx) => { - // Update organization/timestamps + // Update timestamp const [result] = await connection( "swuTeamQuestionResponseEvaluations" ) @@ -325,6 +324,61 @@ async function updateSWUTeamQuestionResponseEvaluationScores( } } +export const updateSWUTeamQuestionResponseEvaluationStatus = tryDb< + [Id, SWUTeamQuestionResponseEvaluationStatus, string, AuthenticatedSession], + SWUTeamQuestionResponseEvaluation +>(async (connection, evaluationId, status, note, session) => { + const now = new Date(); + return valid( + await connection.transaction(async (trx) => { + const [statusRecord] = + await connection( + "swuTeamQuestionResponseEvaluationStatuses" + ) + .transacting(trx) + .insert( + { + id: generateUuid(), + teamQuestionResponseEvaluation: evaluationId, + createdAt: now, + createdBy: session.user.id, + status, + note + }, + "*" + ); + + // Update proposal root record + await connection( + "swuTeamQuestionResponseEvaluations" + ) + .transacting(trx) + .where({ id: evaluationId }) + .update( + { + updatedAt: now + }, + "*" + ); + + if (!statusRecord) { + throw new Error("unable to update team question evaluation"); + } + + const dbResult = await readOneSWUTeamQuestionResponseEvaluation( + trx, + statusRecord.teamQuestionResponseEvaluation, + session + ); + if (isInvalid(dbResult) || !dbResult.value) { + throw new Error("unable to update team question evaluation"); + } + + return dbResult.value; + }) + ); +}); + function generateSWUTeamQuestionResponseEvaluationQuery( connection: Connection ) { From a33cdcfac3c091f2b8e8b75c590cccb2eab836f0 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 28 Aug 2024 15:07:33 -0700 Subject: [PATCH 08/58] fix: typescript compilation errors --- .../question-evaluation/sprint-with-us.ts | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts index fbb40bba4..1e4567f17 100644 --- a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts @@ -20,7 +20,7 @@ import { SWUTeamQuestionResponseEvaluationStatus, SWUTeamQuestionResponseEvaluationType, CreateRequestBody as SharedCreateRequestBody, - UpdateRequestBody, + UpdateRequestBody as SharedUpdateRequestBody, UpdateValidationErrors, isValidStatusChange } from "shared/lib/resources/question-evaluation/sprint-with-us"; @@ -56,6 +56,8 @@ type CreateRequestBody = Omit & { type: string; }; +type UpdateRequestBody = SharedUpdateRequestBody | null; + const routeNamespace = "question-evaluations/sprint-with-us"; const create: crud.Create< @@ -229,7 +231,7 @@ const update: crud.Update< }, async validateRequestBody(request) { if (!request.body) { - return invalid({ proposal: adt("parseFailure" as const) }); + return invalid({ evaluation: adt("parseFailure" as const) }); } if (!permissions.isSignedIn(request.session)) { return invalid({ @@ -266,6 +268,18 @@ const update: crud.Update< case "edit": { const scores = request.body.value.scores; + // Only drafts and submitted consensuses can be edited + if ( + validatedSWUTeamQuestionResponseEvaluation.value.status !== + SWUTeamQuestionResponseEvaluationStatus.Draft && + validatedSWUTeamQuestionResponseEvaluation.value.type !== + SWUTeamQuestionResponseEvaluationType.Conensus + ) { + return invalid({ + permissions: [permissions.ERROR_MESSAGE] + }); + } + const fullProposal = await db.readOneSWUProposal( connection, validatedSWUTeamQuestionResponseEvaluation.value.proposal.id, @@ -279,14 +293,15 @@ const update: crud.Update< ); if (isValid(validatedScores)) { - return valid( - adt( + return valid({ + session: request.session, + body: adt( "edit" as const, { scores: validatedScores.value } as ValidatedUpdateEditRequestBody ) - ); + }); } else { return invalid({ evaluation: adt("edit" as const, { @@ -348,7 +363,7 @@ const update: crud.Update< questionEvaluationValidation.validateNote(request.body.value); if (isInvalid(validatedSubmissionNote)) { return invalid({ - proposal: adt("submit" as const, validatedSubmissionNote.value) + evaluation: adt("submit" as const, validatedSubmissionNote.value) }); } return valid({ @@ -357,7 +372,7 @@ const update: crud.Update< } as ValidatedUpdateRequestBody); } default: - return invalid({ proposal: adt("parseFailure" as const) }); + return invalid({ evaluation: adt("parseFailure" as const) }); } }, respond: wrapRespond< @@ -379,21 +394,13 @@ const update: crud.Update< ); break; case "submit": - dbResult = await db.updateSWUProposalStatus( + dbResult = await db.updateSWUTeamQuestionResponseEvaluationStatus( connection, request.params.id, - SWUProposalStatus.Submitted, + SWUTeamQuestionResponseEvaluationStatus.Submitted, body.value, session ); - // Notify of submission - if (isValid(dbResult)) { - swuProposalNotifications.handleSWUProposalSubmitted( - connection, - request.params.id, - request.body.session - ); - } break; } if (isInvalid(dbResult)) { From 86397ad2dfd87ccef848f1b7d3f1f8328537da0c Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 28 Aug 2024 15:36:05 -0700 Subject: [PATCH 09/58] feat: update types --- .../question-evaluation/sprint-with-us.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/shared/lib/resources/question-evaluation/sprint-with-us.ts b/src/shared/lib/resources/question-evaluation/sprint-with-us.ts index 51b1ba9dc..561a07337 100644 --- a/src/shared/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/shared/lib/resources/question-evaluation/sprint-with-us.ts @@ -90,4 +90,32 @@ export type UpdateRequestBody = | ADT<"edit", UpdateEditRequestBody> | ADT<"submit", string>; -export type UpdateEditRequestBody = CreateRequestBody; +export type UpdateEditRequestBody = Omit< + CreateRequestBody, + "proposal" | "type" | "status" +>; + +type UpdateADTErrors = + | ADT<"edit", UpdateEditValidationErrors> + | ADT<"submit", string[]> + | ADT<"parseFailure">; + +export interface UpdateEditValidationErrors { + scores?: CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors[]; +} + +export interface UpdateValidationErrors extends BodyWithErrors { + evaluation?: UpdateADTErrors; +} + +export function isValidStatusChange( + from: SWUTeamQuestionResponseEvaluationStatus, + to: SWUTeamQuestionResponseEvaluationStatus +): boolean { + switch (from) { + case SWUTeamQuestionResponseEvaluationStatus.Draft: + return SWUTeamQuestionResponseEvaluationStatus.Submitted === to; + default: + return false; + } +} From 2545e69aac606b23c8498307c56f9fcb9a4dc473 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 4 Sep 2024 16:26:49 -0700 Subject: [PATCH 10/58] feat: read evaluation response by proposal and panel member --- .../db/question-evaluation/sprint-with-us.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts index f2bcbafdd..7ef9db230 100644 --- a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts @@ -22,6 +22,7 @@ import { SWUTeamQuestionResponseEvaluation, SWUTeamQuestionResponseEvaluationScores, SWUTeamQuestionResponseEvaluationStatus, + SWUTeamQuestionResponseEvaluationType, UpdateEditRequestBody } from "shared/lib/resources/question-evaluation/sprint-with-us"; import { generateUuid } from "back-end/lib"; @@ -131,6 +132,29 @@ export const isSWUOpportunityEvaluationPanelEvaluator = export const isSWUOpportunityEvaluationPanelChair = makeIsSWUOpportunityEvaluationPanelMember((epm) => epm.chair); +export const readOneSWUTeamQuestionResponseEvaluationByProposalAndEvaluationPanelMember = + tryDb<[Id, Id, SWUTeamQuestionResponseEvaluationType, Session], Id | null>( + async (connection, proposalId, evaluationPanelMemberId, type, session) => { + if (!session) { + return valid(null); + } + const result = ( + await connection( + "swuTeamQuestionResponseEvaluations" + ) + .where({ + proposal: proposalId, + evaluationPanelMember: evaluationPanelMemberId, + type + }) + .select("id") + .first() + )?.id; + + return valid(result ? result : null); + } + ); + export const readManyTeamQuestionResponseEvaluationScores = tryDb< [Id], RawSWUTeamQuestionResponseEvaluationScores[] From e04d4a26e8f4c142b996dfd376d9cb6515d0b041 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Thu, 5 Sep 2024 13:39:45 -0700 Subject: [PATCH 11/58] feat: validate score note function --- .../lib/validation/question-evaluation/sprint-with-us.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/shared/lib/validation/question-evaluation/sprint-with-us.ts b/src/shared/lib/validation/question-evaluation/sprint-with-us.ts index 6a910bf7c..c77ece8c1 100644 --- a/src/shared/lib/validation/question-evaluation/sprint-with-us.ts +++ b/src/shared/lib/validation/question-evaluation/sprint-with-us.ts @@ -16,6 +16,7 @@ import { isInvalid, valid, validateArrayCustom, + validateGenericString, validateGenericStringWords, validateNumber, Validation @@ -149,3 +150,7 @@ export function validateSWUTeamQuestionResponseEvaluationScores( {} ); } + +export function validateNote(raw: string): Validation { + return validateGenericString(raw, "Note", 0, 5000); +} From 67d34ae4caaa331c4696e4c2c0ef4c4895f6e7db Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Thu, 5 Sep 2024 14:40:03 -0700 Subject: [PATCH 12/58] refactor: don't allow multiple evaluations of type per evaluator --- .../question-evaluation/sprint-with-us.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts index 1e4567f17..1765a263a 100644 --- a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts @@ -147,6 +147,28 @@ const create: crud.Create< }); } + // Check for existing evaluation on this proposal, authored by this user + const dbResultEvaluation = + await db.readOneSWUTeamQuestionResponseEvaluationByProposalAndEvaluationPanelMember( + connection, + validatedSWUProposal.value.id, + validatedSWUPanelEvaluationPanelMember.value.id, + validatedType.value, + request.session + ); + if (isInvalid(dbResultEvaluation)) { + return invalid({ + database: [db.ERROR_MESSAGE] + }); + } + if (dbResultEvaluation.value) { + return invalid({ + conflict: [ + "You already have an team question evaluation for this proposal." + ] + }); + } + const validatedScores = questionEvaluationValidation.validateSWUTeamQuestionResponseEvaluationScores( scores, From 5cd1c41de06f3bf2e3b27ff85d96cd8f7160eec4 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Fri, 6 Sep 2024 15:56:51 -0700 Subject: [PATCH 13/58] feat: first attempt at read many swu evaluations permission --- src/back-end/lib/permissions.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/back-end/lib/permissions.ts b/src/back-end/lib/permissions.ts index d0a6ecf0b..df2f1c80a 100644 --- a/src/back-end/lib/permissions.ts +++ b/src/back-end/lib/permissions.ts @@ -873,6 +873,32 @@ export async function deleteSWUProposal( // SWU Team Question Response Evaluations +export async function readManySWUTeamQuestionResponseEvaluations( + connection: Connection, + session: Session, + proposal: SWUProposal +): Promise { + return ( + !!session && + (isAdmin(session) || isGovernment(session)) && + // Filtered to authored evaluations elsewhere when proposal status is + // in individual evaluation + (((proposal.status === SWUProposalStatus.TeamQuestionsPanelIndividual || + proposal.status === SWUProposalStatus.TeamQuestionsPanelConsensus) && + (await isSWUOpportunityEvaluationPanelEvaluator( + connection, + session, + proposal.opportunity.id + ))) || + (proposal.status === SWUProposalStatus.TeamQuestionsPanelConsensus && + (await isSWUOpportunityEvaluationPanelChair( + connection, + session, + proposal.opportunity.id + )))) + ); +} + export async function createSWUTeamQuestionResponseEvaluation( connection: Connection, session: Session, From 30d6a03696f80765e8681240b1c4c10d01b07397 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Fri, 6 Sep 2024 15:57:57 -0700 Subject: [PATCH 14/58] refactor: move new panel statuses to proposal --- src/shared/lib/resources/proposal/sprint-with-us.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/shared/lib/resources/proposal/sprint-with-us.ts b/src/shared/lib/resources/proposal/sprint-with-us.ts index d99bf257b..984e68cac 100644 --- a/src/shared/lib/resources/proposal/sprint-with-us.ts +++ b/src/shared/lib/resources/proposal/sprint-with-us.ts @@ -50,6 +50,8 @@ export function swuProposalPhaseTypeToTitleCase( export enum SWUProposalStatus { Draft = "DRAFT", Submitted = "SUBMITTED", + TeamQuestionsPanelIndividual = "QUESTIONS_PANEL_INDIVIDUAL", + TeamQuestionsPanelConsensus = "QUESTIONS_PANEL_CONESENSUS", UnderReviewTeamQuestions = "UNDER_REVIEW_QUESTIONS", EvaluatedTeamQuestions = "EVALUATED_QUESTIONS", UnderReviewCodeChallenge = "UNDER_REVIEW_CODE_CHALLENGE", @@ -107,6 +109,8 @@ function quantifySWUProposalStatusForSort(a: SWUProposalStatus): number { return 0; case SWUProposalStatus.NotAwarded: return 1; + case SWUProposalStatus.TeamQuestionsPanelIndividual: + case SWUProposalStatus.TeamQuestionsPanelConsensus: case SWUProposalStatus.UnderReviewTeamQuestions: case SWUProposalStatus.EvaluatedTeamQuestions: case SWUProposalStatus.UnderReviewCodeChallenge: From 067b64bc6b3f0a06a28fc5d5b4cf8537452d2024 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Fri, 6 Sep 2024 15:58:48 -0700 Subject: [PATCH 15/58] refactor: use proposal evaluation statuses instead of opportunity statuses --- src/back-end/lib/permissions.ts | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/back-end/lib/permissions.ts b/src/back-end/lib/permissions.ts index df2f1c80a..d10fca1e6 100644 --- a/src/back-end/lib/permissions.ts +++ b/src/back-end/lib/permissions.ts @@ -907,15 +907,13 @@ export async function createSWUTeamQuestionResponseEvaluation( return ( !!session && (isAdmin(session) || isGovernment(session)) && - ((proposal.opportunity.status === - SWUOpportunityStatus.TeamQuestionsPanelEvaluation && + ((proposal.status === SWUProposalStatus.TeamQuestionsPanelIndividual && (await isSWUOpportunityEvaluationPanelEvaluator( connection, session, proposal.opportunity.id ))) || - (proposal.opportunity.status === - SWUOpportunityStatus.TeamQuestionsPanelConsensus && + (proposal.status === SWUProposalStatus.TeamQuestionsPanelConsensus && (await isSWUOpportunityEvaluationPanelChair( connection, session, @@ -932,11 +930,28 @@ export function editSWUTeamQuestionResponseEvaluation( !!session && (isAdmin(session) || isGovernment(session)) && evaluation.evaluationPanelMember.user.id === session.user.id && - ((evaluation.proposal.opportunity.status === - SWUOpportunityStatus.TeamQuestionsPanelEvaluation && + ((evaluation.proposal.status === + SWUProposalStatus.TeamQuestionsPanelIndividual && + evaluation.type === SWUTeamQuestionResponseEvaluationType.Individual) || + (evaluation.proposal.status === + SWUProposalStatus.TeamQuestionsPanelIndividual && + evaluation.type === SWUTeamQuestionResponseEvaluationType.Conensus)) + ); +} + +export function submitSWUTeamQuestionResponseEvaluation( + session: Session, + evaluation: SWUTeamQuestionResponseEvaluation +): boolean { + return ( + !!session && + (isAdmin(session) || isGovernment(session)) && + evaluation.evaluationPanelMember.user.id === session.user.id && + ((evaluation.proposal.status === + SWUProposalStatus.TeamQuestionsPanelIndividual && evaluation.type === SWUTeamQuestionResponseEvaluationType.Individual) || - (evaluation.proposal.opportunity.status === - SWUOpportunityStatus.TeamQuestionsPanelConsensus && + (evaluation.proposal.status === + SWUProposalStatus.TeamQuestionsPanelConsensus && evaluation.type === SWUTeamQuestionResponseEvaluationType.Conensus)) ); } From cc4676e32bfe34a9a110764847410cfac5986fde Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Fri, 6 Sep 2024 16:03:41 -0700 Subject: [PATCH 16/58] refactor: combine statuses for opportunity panel evaluation --- .../lib/pages/opportunity/sprint-with-us/lib/index.ts | 8 ++------ src/shared/lib/resources/opportunity/sprint-with-us.ts | 6 ++---- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/lib/index.ts b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/lib/index.ts index 31329ade9..7640824cc 100644 --- a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/lib/index.ts +++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/lib/index.ts @@ -18,9 +18,7 @@ export function swuOpportunityStatusToColor( return "warning"; case SWUOpportunityStatus.Published: return "success"; - case SWUOpportunityStatus.TeamQuestionsPanelEvaluation: - return "warning"; - case SWUOpportunityStatus.TeamQuestionsPanelConsensus: + case SWUOpportunityStatus.EvaluationTeamQuestionsPanel: return "warning"; case SWUOpportunityStatus.EvaluationTeamQuestions: return "warning"; @@ -47,10 +45,8 @@ export function swuOpportunityStatusToTitleCase( return "Under Review"; case SWUOpportunityStatus.Published: return "Published"; - case SWUOpportunityStatus.TeamQuestionsPanelEvaluation: + case SWUOpportunityStatus.EvaluationTeamQuestionsPanel: return "Evaluation"; - case SWUOpportunityStatus.TeamQuestionsPanelConsensus: - return "Consensus"; case SWUOpportunityStatus.EvaluationTeamQuestions: return "Team Questions"; case SWUOpportunityStatus.EvaluationCodeChallenge: diff --git a/src/shared/lib/resources/opportunity/sprint-with-us.ts b/src/shared/lib/resources/opportunity/sprint-with-us.ts index 8e2c943d7..d63e5745d 100644 --- a/src/shared/lib/resources/opportunity/sprint-with-us.ts +++ b/src/shared/lib/resources/opportunity/sprint-with-us.ts @@ -57,8 +57,7 @@ export enum SWUOpportunityStatus { Draft = "DRAFT", UnderReview = "UNDER_REVIEW", Published = "PUBLISHED", - TeamQuestionsPanelEvaluation = "QUESTIONS_PANEL_EVAL", - TeamQuestionsPanelConsensus = "QUESTIONS_PANEL_CONSENSUS", + EvaluationTeamQuestionsPanel = "EVAL_QUESTIONS_PANEL", EvaluationTeamQuestions = "EVAL_QUESTIONS", EvaluationCodeChallenge = "EVAL_CC", EvaluationTeamScenario = "EVAL_SCENARIO", @@ -124,8 +123,7 @@ export function isSWUOpportunityStatusInEvaluation( export const publicOpportunityStatuses: readonly SWUOpportunityStatus[] = [ SWUOpportunityStatus.Published, - SWUOpportunityStatus.TeamQuestionsPanelEvaluation, - SWUOpportunityStatus.TeamQuestionsPanelConsensus, + SWUOpportunityStatus.EvaluationTeamQuestionsPanel, SWUOpportunityStatus.EvaluationTeamQuestions, SWUOpportunityStatus.EvaluationCodeChallenge, SWUOpportunityStatus.EvaluationTeamScenario, From be7257cc7db17059904f1c95352c6888251a3edc Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Fri, 6 Sep 2024 16:17:10 -0700 Subject: [PATCH 17/58] feat: read own swu evaluations permission --- src/back-end/lib/permissions.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/back-end/lib/permissions.ts b/src/back-end/lib/permissions.ts index d10fca1e6..5e1829e46 100644 --- a/src/back-end/lib/permissions.ts +++ b/src/back-end/lib/permissions.ts @@ -899,6 +899,12 @@ export async function readManySWUTeamQuestionResponseEvaluations( ); } +export function readOwnSWUTeamQuestionResponseEvaluations( + session: Session +): boolean { + return isGovernment(session) || isAdmin(session); +} + export async function createSWUTeamQuestionResponseEvaluation( connection: Connection, session: Session, From 72a13e7b9592f2eb55b147b18e0037e0630942dd Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Mon, 9 Sep 2024 15:35:38 -0700 Subject: [PATCH 18/58] feat: add slim swu evaluation --- .../lib/resources/question-evaluation/sprint-with-us.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/shared/lib/resources/question-evaluation/sprint-with-us.ts b/src/shared/lib/resources/question-evaluation/sprint-with-us.ts index 561a07337..394e39430 100644 --- a/src/shared/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/shared/lib/resources/question-evaluation/sprint-with-us.ts @@ -56,6 +56,14 @@ export interface SWUTeamQuestionResponseEvaluation { updatedAt: Date; } +export interface SWUTeamQuestionResponseEvaluationSlim + extends Omit< + SWUTeamQuestionResponseEvaluation, + "proposal" | "evaluationPanelMember" | "scores" + > { + scores: Omit[]; +} + // Create. export type CreateSWUTeamQuestionResponseEvaluationStatus = From 048800f66c63fb83c32ac1cefd84d7e761c07c56 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Mon, 9 Sep 2024 15:39:04 -0700 Subject: [PATCH 19/58] feat: raw swu evaluation to swu evaluation slim helper --- .../lib/db/question-evaluation/sprint-with-us.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts index 7ef9db230..87e8c74c7 100644 --- a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts @@ -21,6 +21,7 @@ import { CreateSWUTeamQuestionResponseEvaluationScoreBody, SWUTeamQuestionResponseEvaluation, SWUTeamQuestionResponseEvaluationScores, + SWUTeamQuestionResponseEvaluationSlim, SWUTeamQuestionResponseEvaluationStatus, SWUTeamQuestionResponseEvaluationType, UpdateEditRequestBody @@ -104,6 +105,20 @@ async function RawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation }; } +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluationSlim( + raw: RawSWUTeamQuestionResponseEvaluation +): SWUTeamQuestionResponseEvaluationSlim { + const { proposal, evaluationPanelMember, scores, ...restOfRaw } = raw; + + return { + ...restOfRaw, + scores: scores.map( + ({ notes, teamQuestionResponseEvaluation, ...score }) => score + ) + }; +} + function makeIsSWUOpportunityEvaluationPanelMember( typeFn: (epm: SWUEvaluationPanelMember) => boolean ) { From 1c6ece952153fba7aac0334362f032ea7878a05c Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Mon, 9 Sep 2024 15:40:14 -0700 Subject: [PATCH 20/58] feat: first pass at reading many swu evaluations with proposal --- .../db/question-evaluation/sprint-with-us.ts | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts index 87e8c74c7..93a3cada0 100644 --- a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts @@ -27,6 +27,7 @@ import { UpdateEditRequestBody } from "shared/lib/resources/question-evaluation/sprint-with-us"; import { generateUuid } from "back-end/lib"; +import { SWUProposalStatus } from "shared/lib/resources/proposal/sprint-with-us"; export interface CreateSWUTeamQuestionResponseEvaluationParams extends CreateRequestBody { @@ -63,7 +64,7 @@ export type RawSWUTeamQuestionResponseEvaluationScores = teamQuestionResponseEvaluation: Id; }; -async function RawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation( +async function rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation( connection: Connection, session: Session, raw: RawSWUTeamQuestionResponseEvaluation @@ -105,7 +106,6 @@ async function RawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation }; } -// eslint-disable-next-line @typescript-eslint/no-unused-vars function rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluationSlim( raw: RawSWUTeamQuestionResponseEvaluation ): SWUTeamQuestionResponseEvaluationSlim { @@ -147,6 +147,56 @@ export const isSWUOpportunityEvaluationPanelEvaluator = export const isSWUOpportunityEvaluationPanelChair = makeIsSWUOpportunityEvaluationPanelMember((epm) => epm.chair); +export const readManySWUTeamQuestionResponseEvaluations = tryDb< + [AuthenticatedSession, Id, SWUProposalStatus], + SWUTeamQuestionResponseEvaluationSlim[] +>(async (connection, session, id, proposalStatus) => { + const query = generateSWUTeamQuestionResponseEvaluationQuery( + connection + ).where({ proposal: id }); + + // If proposal is still in individual evaluation, scope results to + // the evaluations they have authored + if (proposalStatus === SWUProposalStatus.TeamQuestionsPanelIndividual) { + query + .join( + "evaluationPanelMembers epm", + "epm.id", + "=", + "evaluations.evaluationPanelMember" + ) + .andWhere({ "epm.user": session.user.id }); + } + + const results = await Promise.all( + ( + await query + ).map(async (result: RawSWUTeamQuestionResponseEvaluation) => { + result.scores = + getValidValue( + await readManyTeamQuestionResponseEvaluationScores( + connection, + result.id + ), + [] + ) ?? []; + return result; + }) + ); + + if (!results) { + throw new Error("unable to read evaluations"); + } + + return valid( + results.map((result) => + rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluationSlim( + result + ) + ) + ); +}); + export const readOneSWUTeamQuestionResponseEvaluationByProposalAndEvaluationPanelMember = tryDb<[Id, Id, SWUTeamQuestionResponseEvaluationType, Session], Id | null>( async (connection, proposalId, evaluationPanelMemberId, type, session) => { @@ -210,7 +260,7 @@ export const readOneSWUTeamQuestionResponseEvaluation = tryDb< return valid( result - ? await RawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation( + ? await rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation( connection, session, result From ada4c96f445b599958e8fb7deba580b19bacd0ac Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Tue, 10 Sep 2024 13:53:11 -0700 Subject: [PATCH 21/58] feat: read one swu proposal slim --- .../lib/db/proposal/sprint-with-us.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/back-end/lib/db/proposal/sprint-with-us.ts b/src/back-end/lib/db/proposal/sprint-with-us.ts index 4b7f929f4..87a20d360 100644 --- a/src/back-end/lib/db/proposal/sprint-with-us.ts +++ b/src/back-end/lib/db/proposal/sprint-with-us.ts @@ -762,6 +762,42 @@ export const readManyProposalTeamQuestionResponses = tryDb< return valid(results); }); +export const readOneSWUProposalSlim = tryDb< + [Id, AuthenticatedSession], + SWUProposalSlim | null +>(async (connection, id, session) => { + const result = await generateSWUProposalQuery(connection) + .where({ "proposals.id": id }) + .first(); + + if (result) { + // Fetch submittedAt date if applicable + result.submittedAt = await getSWUProposalSubmittedAt(connection, result); + + // Fetch team questions (scores only included if admin/owner) + const canReadScores = await readSWUProposalScore( + connection, + session, + result.opportunity, + result.id, + result.status, + result.organization + ); + + // Check for permissions on viewing scores and rank + if (canReadScores) { + // Set scores and rankings + await calculateScores(connection, session, result.opportunity, [result]); + } + } + + return valid( + result + ? await rawSWUProposalSlimToSWUProposalSlim(connection, result, session) + : null + ); +}); + export const readOneSWUProposal = tryDb< [Id, AuthenticatedSession], SWUProposal | null From 475aed91b2b6bfe8afd307aa5b70e54f7df420bf Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Tue, 10 Sep 2024 15:02:19 -0700 Subject: [PATCH 22/58] refactor: read slim proposal --- src/back-end/lib/db/question-evaluation/sprint-with-us.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts index 93a3cada0..453d0d48e 100644 --- a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts @@ -6,7 +6,7 @@ import { getValidValue, isInvalid, valid } from "shared/lib/validation"; import { Connection, Transaction, - readOneSWUProposal, + readOneSWUProposalSlim, tryDb } from "back-end/lib/db"; import { @@ -78,7 +78,7 @@ async function rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation const proposal = session && getValidValue( - await readOneSWUProposal(connection, proposalId, session), + await readOneSWUProposalSlim(connection, proposalId, session), null ); if (!proposal) { From cbf0a128f7e4cbf10a8ad6cc759b834e0952aaba Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Tue, 10 Sep 2024 15:03:47 -0700 Subject: [PATCH 23/58] refactor: include proposal in slim evaluation --- .../db/question-evaluation/sprint-with-us.ts | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts index 453d0d48e..ca6cad053 100644 --- a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts @@ -106,13 +106,31 @@ async function rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation }; } -function rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluationSlim( +async function rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluationSlim( + connection: Connection, + session: Session, raw: RawSWUTeamQuestionResponseEvaluation -): SWUTeamQuestionResponseEvaluationSlim { - const { proposal, evaluationPanelMember, scores, ...restOfRaw } = raw; +): Promise { + const { + proposal: proposalId, + evaluationPanelMember, + scores, + ...restOfRaw + } = raw; + + const proposal = + session && + getValidValue( + await readOneSWUProposalSlim(connection, proposalId, session), + null + ); + if (!proposal) { + throw new Error("unable to process team question response evaluation"); + } return { ...restOfRaw, + proposal, scores: scores.map( ({ notes, teamQuestionResponseEvaluation, ...score }) => score ) @@ -189,9 +207,14 @@ export const readManySWUTeamQuestionResponseEvaluations = tryDb< } return valid( - results.map((result) => - rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluationSlim( - result + await Promise.all( + results.map( + async (result) => + await rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluationSlim( + connection, + session, + result + ) ) ) ); From 47af2b40e7190163a10392562a6ff66a78f7c51f Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Tue, 10 Sep 2024 15:22:26 -0700 Subject: [PATCH 24/58] feat: read own swu evaluations --- .../db/question-evaluation/sprint-with-us.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts index ca6cad053..7537574dc 100644 --- a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts @@ -220,6 +220,35 @@ export const readManySWUTeamQuestionResponseEvaluations = tryDb< ); }); +export const readOwnSWUTeamQuestionResponseEvaluations = tryDb< + [AuthenticatedSession], + SWUTeamQuestionResponseEvaluationSlim[] +>(async (connection, session) => { + const evaluations = await generateSWUTeamQuestionResponseEvaluationQuery( + connection + ) + .join( + "evaluationPanelMembers epm", + "epm.id", + "=", + "evaluations.evaluationPanelMember" + ) + .andWhere({ "epm.user": session.user.id }); + + return valid( + await Promise.all( + evaluations.map( + async (result) => + await rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluationSlim( + connection, + session, + result + ) + ) + ) + ); +}); + export const readOneSWUTeamQuestionResponseEvaluationByProposalAndEvaluationPanelMember = tryDb<[Id, Id, SWUTeamQuestionResponseEvaluationType, Session], Id | null>( async (connection, proposalId, evaluationPanelMemberId, type, session) => { From 680be756f48b4cc6de7281ead1746fce5f3e821a Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Tue, 10 Sep 2024 15:22:49 -0700 Subject: [PATCH 25/58] feat: read many swu evaluations resource --- .../question-evaluation/sprint-with-us.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts index 1765a263a..9e454549b 100644 --- a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts @@ -5,6 +5,7 @@ import { JsonResponseBody, basicResponse, makeJsonResponseBody, + nullRequestBodyHandler, wrapRespond } from "back-end/lib/server"; import { @@ -17,6 +18,7 @@ import { CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors, CreateValidationErrors, SWUTeamQuestionResponseEvaluation, + SWUTeamQuestionResponseEvaluationSlim, SWUTeamQuestionResponseEvaluationStatus, SWUTeamQuestionResponseEvaluationType, CreateRequestBody as SharedCreateRequestBody, @@ -60,6 +62,69 @@ type UpdateRequestBody = SharedUpdateRequestBody | null; const routeNamespace = "question-evaluations/sprint-with-us"; +const readMany: crud.ReadMany = ( + connection: db.Connection +) => { + return nullRequestBodyHandler< + JsonResponseBody, + Session + >(async (request) => { + const respond = ( + code: number, + body: SWUTeamQuestionResponseEvaluationSlim[] | string[] + ) => basicResponse(code, request.session, makeJsonResponseBody(body)); + if (request.query.proposal) { + if (!permissions.isSignedIn(request.session)) { + return respond(401, [permissions.ERROR_MESSAGE]); + } + + const validatedSWUProposal = await validateSWUProposalId( + connection, + request.query.proposal, + request.session + ); + if (isInvalid(validatedSWUProposal)) { + return respond(404, ["Sprint With Us proposal not found."]); + } + + if ( + !(await permissions.readManySWUTeamQuestionResponseEvaluations( + connection, + request.session, + validatedSWUProposal.value + )) + ) { + return respond(401, [permissions.ERROR_MESSAGE]); + } + const dbResult = await db.readManySWUTeamQuestionResponseEvaluations( + connection, + request.session, + request.query.proposal, + validatedSWUProposal.value.status + ); + if (isInvalid(dbResult)) { + return respond(503, [db.ERROR_MESSAGE]); + } + return respond(200, dbResult.value); + } else { + if ( + !permissions.isSignedIn(request.session) || + !permissions.readOwnSWUTeamQuestionResponseEvaluations(request.session) + ) { + return respond(401, [permissions.ERROR_MESSAGE]); + } + const dbResult = await db.readOwnSWUTeamQuestionResponseEvaluations( + connection, + request.session + ); + if (isInvalid(dbResult)) { + return respond(503, [db.ERROR_MESSAGE]); + } + return respond(200, dbResult.value); + } + }); +}; + const create: crud.Create< Session, db.Connection, @@ -451,6 +516,7 @@ const update: crud.Update< const resource: crud.BasicCrudResource = { routeNamespace, + readMany, create, update }; From f7e59c26de2a59db4b5d20cdc2752676765566a1 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Tue, 10 Sep 2024 16:13:32 -0700 Subject: [PATCH 26/58] fix: allow gov users to view evaluations when opportunity has moved beyond evaluations --- src/back-end/lib/permissions.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/back-end/lib/permissions.ts b/src/back-end/lib/permissions.ts index 5e1829e46..080f97c97 100644 --- a/src/back-end/lib/permissions.ts +++ b/src/back-end/lib/permissions.ts @@ -25,6 +25,7 @@ import { } from "shared/lib/resources/opportunity/code-with-us"; import { CreateSWUOpportunityStatus, + doesSWUOpportunityStatusAllowGovToViewEvaluations, doesSWUOpportunityStatusAllowGovToViewProposals, SWUOpportunity, SWUOpportunityStatus @@ -881,15 +882,18 @@ export async function readManySWUTeamQuestionResponseEvaluations( return ( !!session && (isAdmin(session) || isGovernment(session)) && - // Filtered to authored evaluations elsewhere when proposal status is - // in individual evaluation - (((proposal.status === SWUProposalStatus.TeamQuestionsPanelIndividual || - proposal.status === SWUProposalStatus.TeamQuestionsPanelConsensus) && - (await isSWUOpportunityEvaluationPanelEvaluator( - connection, - session, - proposal.opportunity.id - ))) || + (doesSWUOpportunityStatusAllowGovToViewEvaluations( + proposal.opportunity.status + ) || + // Filtered to authored evaluations elsewhere when proposal status is + // in individual evaluation + ((proposal.status === SWUProposalStatus.TeamQuestionsPanelIndividual || + proposal.status === SWUProposalStatus.TeamQuestionsPanelConsensus) && + (await isSWUOpportunityEvaluationPanelEvaluator( + connection, + session, + proposal.opportunity.id + ))) || (proposal.status === SWUProposalStatus.TeamQuestionsPanelConsensus && (await isSWUOpportunityEvaluationPanelChair( connection, From 2c08cb76cc3590d6d7e417ea45ae31f6a877cd50 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 11 Sep 2024 14:26:52 -0700 Subject: [PATCH 27/58] feat: read one swu evaluation permission --- src/back-end/lib/permissions.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/back-end/lib/permissions.ts b/src/back-end/lib/permissions.ts index 080f97c97..fd1acd69a 100644 --- a/src/back-end/lib/permissions.ts +++ b/src/back-end/lib/permissions.ts @@ -874,6 +874,36 @@ export async function deleteSWUProposal( // SWU Team Question Response Evaluations +export async function readOneSWUTeamQuestionResponseEvaluation( + connection: Connection, + session: Session, + evaluation: SWUTeamQuestionResponseEvaluation +): Promise { + return ( + !!session && + (isAdmin(session) || isGovernment(session)) && + (doesSWUOpportunityStatusAllowGovToViewEvaluations( + evaluation.proposal.opportunity.status + ) || + (evaluation.proposal.status === + SWUProposalStatus.TeamQuestionsPanelIndividual && + evaluation.type === SWUTeamQuestionResponseEvaluationType.Individual && + evaluation.evaluationPanelMember.user.id === session.user.id) || + (evaluation.proposal.status === + SWUProposalStatus.TeamQuestionsPanelConsensus && + ((await isSWUOpportunityEvaluationPanelEvaluator( + connection, + session, + evaluation.proposal.opportunity.id + )) || + (await isSWUOpportunityEvaluationPanelChair( + connection, + session, + evaluation.proposal.opportunity.id + ))))) + ); +} + export async function readManySWUTeamQuestionResponseEvaluations( connection: Connection, session: Session, From 055b63b9e2d086143bad1c9e81205c56f8fe37c1 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 11 Sep 2024 14:43:53 -0700 Subject: [PATCH 28/58] feat: read one swu evaluation resource --- .../question-evaluation/sprint-with-us.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts index 9e454549b..9acb45bde 100644 --- a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts @@ -125,6 +125,42 @@ const readMany: crud.ReadMany = ( }); }; +const readOne: crud.ReadOne = ( + connection: db.Connection +) => { + return nullRequestBodyHandler< + JsonResponseBody, + Session + >(async (request) => { + const respond = ( + code: number, + body: SWUTeamQuestionResponseEvaluation | string[] + ) => basicResponse(code, request.session, makeJsonResponseBody(body)); + if (!permissions.isSignedIn(request.session)) { + return respond(401, [permissions.ERROR_MESSAGE]); + } + const validatedSWUTeamQuestionResponseEvaluation = + await validateSWUTeamQuestionResponseEvaluationId( + connection, + request.params.id, + request.session + ); + if (isInvalid(validatedSWUTeamQuestionResponseEvaluation)) { + return respond(404, ["Evaluation not found."]); + } + if ( + !(await permissions.readOneSWUTeamQuestionResponseEvaluation( + connection, + request.session, + validatedSWUTeamQuestionResponseEvaluation.value + )) + ) { + return respond(401, [permissions.ERROR_MESSAGE]); + } + return respond(200, validatedSWUTeamQuestionResponseEvaluation.value); + }); +}; + const create: crud.Create< Session, db.Connection, @@ -516,6 +552,7 @@ const update: crud.Update< const resource: crud.BasicCrudResource = { routeNamespace, + readOne, readMany, create, update From e921cc0b0b6e61233811b65c8ef3fd3fc30b4b05 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 11 Sep 2024 15:02:23 -0700 Subject: [PATCH 29/58] fix: use appropriate proposal status for edit permission --- src/back-end/lib/permissions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/back-end/lib/permissions.ts b/src/back-end/lib/permissions.ts index fd1acd69a..696701d1b 100644 --- a/src/back-end/lib/permissions.ts +++ b/src/back-end/lib/permissions.ts @@ -974,7 +974,7 @@ export function editSWUTeamQuestionResponseEvaluation( SWUProposalStatus.TeamQuestionsPanelIndividual && evaluation.type === SWUTeamQuestionResponseEvaluationType.Individual) || (evaluation.proposal.status === - SWUProposalStatus.TeamQuestionsPanelIndividual && + SWUProposalStatus.TeamQuestionsPanelConsensus && evaluation.type === SWUTeamQuestionResponseEvaluationType.Conensus)) ); } From f3dd5df6e6b011d7c25fba282562905c8a9be8c4 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Mon, 16 Sep 2024 09:01:15 -0700 Subject: [PATCH 30/58] fix: adjust read many permission to be based on opportunities --- src/back-end/lib/permissions.ts | 44 ++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/back-end/lib/permissions.ts b/src/back-end/lib/permissions.ts index 696701d1b..b21f4c160 100644 --- a/src/back-end/lib/permissions.ts +++ b/src/back-end/lib/permissions.ts @@ -25,7 +25,7 @@ import { } from "shared/lib/resources/opportunity/code-with-us"; import { CreateSWUOpportunityStatus, - doesSWUOpportunityStatusAllowGovToViewEvaluations, + doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations, doesSWUOpportunityStatusAllowGovToViewProposals, SWUOpportunity, SWUOpportunityStatus @@ -882,7 +882,7 @@ export async function readOneSWUTeamQuestionResponseEvaluation( return ( !!session && (isAdmin(session) || isGovernment(session)) && - (doesSWUOpportunityStatusAllowGovToViewEvaluations( + (doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations( evaluation.proposal.opportunity.status ) || (evaluation.proposal.status === @@ -907,29 +907,33 @@ export async function readOneSWUTeamQuestionResponseEvaluation( export async function readManySWUTeamQuestionResponseEvaluations( connection: Connection, session: Session, - proposal: SWUProposal + opportunity: SWUOpportunity, + isConsensus: boolean ): Promise { return ( !!session && (isAdmin(session) || isGovernment(session)) && - (doesSWUOpportunityStatusAllowGovToViewEvaluations( - proposal.opportunity.status + (doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations( + opportunity.status ) || - // Filtered to authored evaluations elsewhere when proposal status is - // in individual evaluation - ((proposal.status === SWUProposalStatus.TeamQuestionsPanelIndividual || - proposal.status === SWUProposalStatus.TeamQuestionsPanelConsensus) && - (await isSWUOpportunityEvaluationPanelEvaluator( - connection, - session, - proposal.opportunity.id - ))) || - (proposal.status === SWUProposalStatus.TeamQuestionsPanelConsensus && - (await isSWUOpportunityEvaluationPanelChair( - connection, - session, - proposal.opportunity.id - )))) + (isConsensus + ? (await isSWUOpportunityEvaluationPanelEvaluator( + connection, + session, + opportunity.id + )) || + (await isSWUOpportunityEvaluationPanelChair( + connection, + session, + opportunity.id + )) + : // Filtered to authored evaluations elsewhere when evaluation is + // individual + await isSWUOpportunityEvaluationPanelEvaluator( + connection, + session, + opportunity.id + ))) ); } From b23e0e341da1eac7344425518b517728cb942070 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Mon, 16 Sep 2024 09:02:33 -0700 Subject: [PATCH 31/58] fix: query many evaluations using opportunity id --- .../db/question-evaluation/sprint-with-us.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts index 7537574dc..07b85e83f 100644 --- a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts @@ -27,7 +27,6 @@ import { UpdateEditRequestBody } from "shared/lib/resources/question-evaluation/sprint-with-us"; import { generateUuid } from "back-end/lib"; -import { SWUProposalStatus } from "shared/lib/resources/proposal/sprint-with-us"; export interface CreateSWUTeamQuestionResponseEvaluationParams extends CreateRequestBody { @@ -166,24 +165,24 @@ export const isSWUOpportunityEvaluationPanelChair = makeIsSWUOpportunityEvaluationPanelMember((epm) => epm.chair); export const readManySWUTeamQuestionResponseEvaluations = tryDb< - [AuthenticatedSession, Id, SWUProposalStatus], + [AuthenticatedSession, Id, boolean], SWUTeamQuestionResponseEvaluationSlim[] ->(async (connection, session, id, proposalStatus) => { - const query = generateSWUTeamQuestionResponseEvaluationQuery( - connection - ).where({ proposal: id }); - - // If proposal is still in individual evaluation, scope results to - // the evaluations they have authored - if (proposalStatus === SWUProposalStatus.TeamQuestionsPanelIndividual) { +>(async (connection, session, id, isConsensus) => { + const query = generateSWUTeamQuestionResponseEvaluationQuery(connection) + .join("swuProposals", "swuProposals.id", "=", "evaluations.proposal") + .where({ "swuProposals.opportunity": id }); + + // If not reading consensus evaluations, scope results to those they have + // authored + if (!isConsensus) { query .join( - "evaluationPanelMembers epm", - "epm.id", + "swuEvaluationPanelMembers", + "swuEvaluationPanelMembers.id", "=", "evaluations.evaluationPanelMember" ) - .andWhere({ "epm.user": session.user.id }); + .andWhere({ "swuEvaluationPanelMembers.user": session.user.id }); } const results = await Promise.all( From 5e7314ca4911071a4b50876ef4c83a20eb33ffbd Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Mon, 16 Sep 2024 09:03:50 -0700 Subject: [PATCH 32/58] fix: validate and read many evaluations using opportunity --- .../question-evaluation/sprint-with-us.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts index 9acb45bde..2d1c5f5eb 100644 --- a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts @@ -9,6 +9,7 @@ import { wrapRespond } from "back-end/lib/server"; import { + validateSWUOpportunityId, validateSWUProposalId, validateSWUTeamQuestionResponseEvaluationId } from "back-end/lib/validation"; @@ -73,25 +74,27 @@ const readMany: crud.ReadMany = ( code: number, body: SWUTeamQuestionResponseEvaluationSlim[] | string[] ) => basicResponse(code, request.session, makeJsonResponseBody(body)); - if (request.query.proposal) { + if (request.query.opportunity) { if (!permissions.isSignedIn(request.session)) { return respond(401, [permissions.ERROR_MESSAGE]); } - const validatedSWUProposal = await validateSWUProposalId( + const validatedSWUOpportunity = await validateSWUOpportunityId( connection, - request.query.proposal, + request.query.opportunity, request.session ); - if (isInvalid(validatedSWUProposal)) { - return respond(404, ["Sprint With Us proposal not found."]); + if (isInvalid(validatedSWUOpportunity)) { + return respond(404, ["Sprint With Us opportunity not found."]); } + const isConsensus = Boolean(request.query.consensus); if ( !(await permissions.readManySWUTeamQuestionResponseEvaluations( connection, request.session, - validatedSWUProposal.value + validatedSWUOpportunity.value, + isConsensus )) ) { return respond(401, [permissions.ERROR_MESSAGE]); @@ -99,8 +102,8 @@ const readMany: crud.ReadMany = ( const dbResult = await db.readManySWUTeamQuestionResponseEvaluations( connection, request.session, - request.query.proposal, - validatedSWUProposal.value.status + request.query.opportunity, + isConsensus ); if (isInvalid(dbResult)) { return respond(503, [db.ERROR_MESSAGE]); From aee2b0e902b0958199e0dab6afb4e30fa3758541 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Mon, 16 Sep 2024 09:04:22 -0700 Subject: [PATCH 33/58] feat: add front-end api actions --- .../typescript/lib/http/api/index.ts | 1 + .../lib/http/api/question-evaluation/index.ts | 1 + .../api/question-evaluation/sprint-with-us.ts | 116 ++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 src/front-end/typescript/lib/http/api/question-evaluation/index.ts create mode 100644 src/front-end/typescript/lib/http/api/question-evaluation/sprint-with-us.ts diff --git a/src/front-end/typescript/lib/http/api/index.ts b/src/front-end/typescript/lib/http/api/index.ts index 54d676d39..d771233f1 100644 --- a/src/front-end/typescript/lib/http/api/index.ts +++ b/src/front-end/typescript/lib/http/api/index.ts @@ -60,3 +60,4 @@ export * as proposals from "front-end/lib/http/api/proposal"; export * as sessions from "front-end/lib/http/api/session"; export * as subscribers from "front-end/lib/http/api/subscribers"; export * as users from "front-end/lib/http/api/user"; +export * as evaluations from "front-end/lib/http/api/question-evaluation"; diff --git a/src/front-end/typescript/lib/http/api/question-evaluation/index.ts b/src/front-end/typescript/lib/http/api/question-evaluation/index.ts new file mode 100644 index 000000000..41b10a8e8 --- /dev/null +++ b/src/front-end/typescript/lib/http/api/question-evaluation/index.ts @@ -0,0 +1 @@ +export * as swu from "front-end/lib/http/api/question-evaluation/sprint-with-us"; diff --git a/src/front-end/typescript/lib/http/api/question-evaluation/sprint-with-us.ts b/src/front-end/typescript/lib/http/api/question-evaluation/sprint-with-us.ts new file mode 100644 index 000000000..0dfde7230 --- /dev/null +++ b/src/front-end/typescript/lib/http/api/question-evaluation/sprint-with-us.ts @@ -0,0 +1,116 @@ +import * as crud from "front-end/lib/http/crud"; +import * as Resource from "shared/lib/resources/question-evaluation/sprint-with-us"; +import { compareNumbers } from "shared/lib"; +import { Id } from "shared/lib/types"; + +const NAMESPACE = "question-evaluations/sprint-with-us"; + +export function create(): crud.CreateAction< + Resource.CreateRequestBody, + Resource.SWUTeamQuestionResponseEvaluation, + Resource.CreateValidationErrors, + Msg +> { + return crud.makeCreateAction( + NAMESPACE, + rawSWUTeamQuestionResponseEvaluationToSWUTeamQuestionResponseEvaluation + ); +} + +/** + * Parses URL parameters prior to creating a read request for many SWU proposals + * + * @param opportunityId + * @param consensus + */ +export function readMany( + opportunityId?: Id, + consensus?: boolean +): crud.ReadManyAction< + Resource.SWUTeamQuestionResponseEvaluationSlim, + string[], + Msg +> { + const params = new URLSearchParams({ + opportunity: + opportunityId !== undefined + ? window.encodeURIComponent(opportunityId) + : "" + }); + if (consensus) { + params.append("consensus", "true"); + } + return crud.makeReadManyAction( + NAMESPACE, + rawSWUTeamQuestionResponseEvaluationSlimToSWUTeamQuestionResponseEvaluationSlim, + params.toString() + ); +} + +export function readOne( + opportunityId: Id +): crud.ReadOneAction< + Resource.SWUTeamQuestionResponseEvaluation, + string[], + Msg +> { + return crud.makeReadOneAction( + NAMESPACE, + rawSWUTeamQuestionResponseEvaluationToSWUTeamQuestionResponseEvaluation, + `opportunity=${window.encodeURIComponent(opportunityId)}` + ); +} + +export function update(): crud.UpdateAction< + Resource.UpdateRequestBody, + Resource.SWUTeamQuestionResponseEvaluation, + Resource.UpdateValidationErrors, + Msg +> { + return crud.makeUpdateAction( + NAMESPACE, + rawSWUTeamQuestionResponseEvaluationToSWUTeamQuestionResponseEvaluation + ); +} + +// Raw Conversion + +interface RawSWUTeamQuestionResponseEvaluation + extends Omit< + Resource.SWUTeamQuestionResponseEvaluation, + "createdAt" | "updatedAt" + > { + createdAt: string; + updatedAt: string; +} + +function rawSWUTeamQuestionResponseEvaluationToSWUTeamQuestionResponseEvaluation( + raw: RawSWUTeamQuestionResponseEvaluation +): Resource.SWUTeamQuestionResponseEvaluation { + return { + ...raw, + createdAt: new Date(raw.createdAt), + updatedAt: new Date(raw.updatedAt), + scores: raw.scores.sort((a, b) => compareNumbers(a.order, b.order)) + }; +} + +interface RawSWUTeamQuestionResponseEvaluationSlim + extends Omit< + Resource.SWUTeamQuestionResponseEvaluationSlim, + "createdAt" | "updatedAt" + > { + createdAt: string; + updatedAt: string; +} + +function rawSWUTeamQuestionResponseEvaluationSlimToSWUTeamQuestionResponseEvaluationSlim( + raw: RawSWUTeamQuestionResponseEvaluationSlim +): Resource.SWUTeamQuestionResponseEvaluationSlim { + return { + ...raw, + createdAt: new Date(raw.createdAt), + updatedAt: new Date(raw.updatedAt), + scores: raw.scores.sort((a, b) => compareNumbers(a.order, b.order)) + }; +} From 4e1ea817806bf87f16bb9c0ee99ae10c35d1208a Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Mon, 16 Sep 2024 09:07:30 -0700 Subject: [PATCH 34/58] feat: add proposal panel evaluation statuses to front-end helpers --- .../lib/pages/proposal/sprint-with-us/lib/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/index.ts b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/index.ts index b25976dd8..414d780ba 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/index.ts +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/index.ts @@ -18,6 +18,8 @@ export function swuProposalStatusToColor( case SWUProposalStatus.UnderReviewTeamQuestions: case SWUProposalStatus.UnderReviewCodeChallenge: case SWUProposalStatus.UnderReviewTeamScenario: + case SWUProposalStatus.TeamQuestionsPanelIndividual: + case SWUProposalStatus.TeamQuestionsPanelConsensus: return "warning"; case SWUProposalStatus.EvaluatedTeamQuestions: case SWUProposalStatus.EvaluatedCodeChallenge: @@ -40,6 +42,14 @@ export function swuProposalStatusToTitleCase( return "Draft"; case SWUProposalStatus.Submitted: return "Submitted"; + case SWUProposalStatus.TeamQuestionsPanelIndividual: + return viewerUserType === UserType.Vendor + ? "Under Review" + : "Panel Evaluation (Individual)"; + case SWUProposalStatus.TeamQuestionsPanelConsensus: + return viewerUserType === UserType.Vendor + ? "Under Review" + : "Panel Evaluation (Consensus)"; case SWUProposalStatus.UnderReviewTeamQuestions: return viewerUserType === UserType.Vendor ? "Under Review" From f1f3d1bb49f72ab3703f8dacf5d8f91175d2a4b3 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Mon, 16 Sep 2024 10:22:24 -0700 Subject: [PATCH 35/58] feat: compare swu anonymous proponent numbers --- .../lib/resources/proposal/sprint-with-us.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/shared/lib/resources/proposal/sprint-with-us.ts b/src/shared/lib/resources/proposal/sprint-with-us.ts index 984e68cac..dd528b0ff 100644 --- a/src/shared/lib/resources/proposal/sprint-with-us.ts +++ b/src/shared/lib/resources/proposal/sprint-with-us.ts @@ -128,6 +128,20 @@ function quantifySWUProposalStatusForSort(a: SWUProposalStatus): number { } } +function getSWUProposalAnonymousProponentNumber(proposal: SWUProposalSlim) { + return Number(proposal.anonymousProponentName.match(/\d+/)?.at(0)); +} + +export function compareSWUProposalAnonymousProponentNumber( + a: SWUProposalSlim, + b: SWUProposalSlim +) { + return compareNumbers( + getSWUProposalAnonymousProponentNumber(a), + getSWUProposalAnonymousProponentNumber(b) + ); +} + export function compareSWUProposalStatuses( a: SWUProposalStatus, b: SWUProposalStatus From 6dff34187e4e02366e66692871e14c6f4ba4669e Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Mon, 16 Sep 2024 10:24:56 -0700 Subject: [PATCH 36/58] feat: add evaluations to tabs --- .../opportunity/sprint-with-us/edit/index.tsx | 21 ++++++++++++++++--- .../sprint-with-us/edit/tab/index.ts | 21 ++++++++++++++----- .../sprint-with-us/view/tab/proposal.tsx | 2 ++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/index.tsx b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/index.tsx index 43a53a89d..e717c98b4 100644 --- a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/index.tsx +++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/index.tsx @@ -26,6 +26,7 @@ import { User, UserType, isAdmin } from "shared/lib/resources/user"; import { adt, ADT, Id } from "shared/lib/types"; import { invalid, valid, Validation } from "shared/lib/validation"; import { SWUProposalSlim } from "shared/lib/resources/proposal/sprint-with-us"; +import { SWUTeamQuestionResponseEvaluationSlim } from "shared/lib/resources/question-evaluation/sprint-with-us"; interface ValidState extends Tab.ParentState { opportunity: SWUOpportunity | null; @@ -47,6 +48,7 @@ export type InnerMsg_ = Tab.ParentInnerMsg< Tab.TabId, api.ResponseValidation, api.ResponseValidation, + api.ResponseValidation, User ] > @@ -97,7 +99,7 @@ function makeInit(): component_.page.Init< (msg) => adt("sidebar", msg) as Msg ), ...component_.cmd.mapMany(tabCmds, (msg) => adt("tab", msg) as Msg), - component_.cmd.join( + component_.cmd.join3( api.opportunities.swu.readOne()( routeParams.opportunityId, (response) => response @@ -107,12 +109,19 @@ function makeInit(): component_.page.Init< (response) => response ) : component_.cmd.dispatch(valid([])), - (opportunity, proposals) => + Tab.shouldLoadEvaluationsForTab(tabId) + ? api.evaluations.swu.readMany( + routeParams.opportunityId, + tabId === "consensus" + )((response) => response) + : component_.cmd.dispatch(valid([])), + (opportunity, proposals, evaluations) => adt("onInitResponse", [ routePath, tabId, opportunity, proposals, + evaluations, shared.sessionUser ]) as Msg ) @@ -159,6 +168,7 @@ function makeComponent(): component_.page.Component< tabId, opportunityResponse, proposalsResponse, + evaluationsResponse, viewerUser ] = msg.value; // If the opportunity request failed, then show the "Not Found" page. @@ -179,6 +189,7 @@ function makeComponent(): component_.page.Component< } const opportunity = opportunityResponse.value; const proposals = api.getValidValue(proposalsResponse, []); + const evaluations = api.getValidValue(evaluationsResponse, []); // Tab Permissions const evaluationPanelMember = opportunity.evaluationPanel?.find( @@ -223,7 +234,11 @@ function makeComponent(): component_.page.Component< ), component_.cmd.dispatch( component_.page.mapMsg( - tabComponent.onInitResponse([opportunity, proposals]), + tabComponent.onInitResponse([ + opportunity, + proposals, + evaluations + ]), (msg) => adt("tab", msg) ) as Msg ) diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts index d99a7d8fd..efb91fee0 100644 --- a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts +++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts @@ -5,6 +5,7 @@ import * as AddendaTab from "front-end/lib/pages/opportunity/sprint-with-us/edit import * as CodeChallengeTab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab/code-challenge"; import * as HistoryTab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab/history"; import * as OpportunityTab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab/opportunity"; +import * as OverviewTab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab/overview"; import * as ProposalsTab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab/proposals"; import * as SummaryTab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab/summary"; import * as TeamQuestionsTab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab/team-questions"; @@ -20,6 +21,7 @@ import { User } from "shared/lib/resources/user"; import { adt, Id } from "shared/lib/types"; import { SWUProposalSlim } from "shared/lib/resources/proposal/sprint-with-us"; import { GUIDE_AUDIENCE } from "front-end/lib/pages/guide/view"; +import { SWUTeamQuestionResponseEvaluationSlim } from "shared/lib/resources/question-evaluation/sprint-with-us"; // Parent page types & functions. @@ -48,7 +50,11 @@ export type TabPermissions = { isChair: boolean; }; -export type InitResponse = [SWUOpportunity, SWUProposalSlim[]]; +export type InitResponse = [ + SWUOpportunity, + SWUProposalSlim[], + SWUTeamQuestionResponseEvaluationSlim[] +]; export type Component = TabbedPage.TabComponent< Params, @@ -114,8 +120,8 @@ export interface Tabs { >; overview: TabbedPage.Tab< Params, - SummaryTab.State, - SummaryTab.InnerMsg, + OverviewTab.State, + OverviewTab.InnerMsg, InitResponse >; consensus: TabbedPage.Tab< @@ -231,8 +237,7 @@ export function idToDefinition( } as TabbedPage.TabDefinition; case "overview": return { - // TODO: Create tab - component: SummaryTab.component, + component: OverviewTab.component, icon: "list-check", title: "Overview" } as TabbedPage.TabDefinition; @@ -330,6 +335,7 @@ export function makeSidebarState( export function shouldLoadProposalsForTab(tabId: TabId): boolean { const proposalTabs: TabId[] = [ + "overview", "proposals", "teamQuestions", "codeChallenge", @@ -337,3 +343,8 @@ export function shouldLoadProposalsForTab(tabId: TabId): boolean { ]; return proposalTabs.includes(tabId); } + +export function shouldLoadEvaluationsForTab(tabId: TabId): boolean { + const evaluationTabs: TabId[] = ["overview"]; + return evaluationTabs.includes(tabId); +} diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/proposal.tsx b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/proposal.tsx index db4989099..311a8635d 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/proposal.tsx +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/proposal.tsx @@ -518,6 +518,8 @@ export const component: Tab.Component = { onClick: () => dispatch(adt("showModal", "award" as const)) } ]); + case SWUProposalStatus.TeamQuestionsPanelIndividual: + case SWUProposalStatus.TeamQuestionsPanelConsensus: case SWUProposalStatus.Draft: case SWUProposalStatus.Submitted: case SWUProposalStatus.Withdrawn: From 565e8121ec9519895e039f6453e28b90251fbfd2 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Mon, 16 Sep 2024 13:14:32 -0700 Subject: [PATCH 37/58] feat: overview scaffolding --- .../sprint-with-us/edit/tab/overview.tsx | 427 ++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx new file mode 100644 index 000000000..49964cbe7 --- /dev/null +++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx @@ -0,0 +1,427 @@ +import { EMPTY_STRING } from "front-end/config"; +import { Route } from "front-end/lib/app/types"; +import * as Table from "front-end/lib/components/table"; +import { + Immutable, + immutable, + component as component_ +} from "front-end/lib/framework"; +import * as api from "front-end/lib/http/api"; +import * as Tab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab"; +import * as toasts from "front-end/lib/pages/opportunity/sprint-with-us/lib/toasts"; +import EditTabHeader from "front-end/lib/pages/opportunity/sprint-with-us/lib/views/edit-tab-header"; +import Link, { routeDest } from "front-end/lib/views/link"; +import ReportCardList, { + ReportCard +} from "front-end/lib/views/report-card-list"; +import React from "react"; +import { Col, Row } from "reactstrap"; +import { + canViewSWUOpportunityTeamQuestionResponseEvaluations, + isSWUOpportunityAcceptingProposals, + SWUOpportunity, + SWUOpportunityStatus +} from "shared/lib/resources/opportunity/sprint-with-us"; +import { + compareSWUProposalAnonymousProponentNumber, + getSWUProponentName, + NUM_SCORE_DECIMALS, + SWUProposalSlim, + UpdateValidationErrors +} from "shared/lib/resources/proposal/sprint-with-us"; +import { + SWUTeamQuestionResponseEvaluation, + SWUTeamQuestionResponseEvaluationSlim, + canSWUTeamQuestionResponseEvaluationBeSubmitted +} from "shared/lib/resources/question-evaluation/sprint-with-us"; +import { ADT, adt } from "shared/lib/types"; + +export interface State extends Tab.Params { + opportunity: SWUOpportunity | null; + submitLoading: boolean; + canEvaluationsBeSubmitted: boolean; + canViewEvaluations: boolean; + evaluations: SWUTeamQuestionResponseEvaluationSlim[]; + proposals: SWUProposalSlim[]; + table: Immutable; +} + +export type InnerMsg = + | ADT<"onInitResponse", Tab.InitResponse> + | ADT<"table", Table.Msg> + | ADT<"submit"> + | ADT< + "onSubmitResponse", + api.ResponseValidation< + SWUTeamQuestionResponseEvaluationSlim, + UpdateValidationErrors + >[] + >; + +export type Msg = component_.page.Msg; + +const init: component_.base.Init = (params) => { + const [tableState, tableCmds] = Table.init({ + idNamespace: "evaluations-table" + }); + return [ + { + ...params, + opportunity: null, + submitLoading: false, + canViewEvaluations: false, + canEvaluationsBeSubmitted: false, + evaluations: [], + proposals: [], + table: immutable(tableState) + }, + component_.cmd.mapMany(tableCmds, (msg) => adt("table", msg) as Msg) + ]; +}; + +const update: component_.page.Update = ({ + state, + msg +}) => { + switch (msg.tag) { + case "onInitResponse": { + const opportunity = msg.value[0]; + const proposals = msg.value[1].sort((a, b) => + compareSWUProposalAnonymousProponentNumber(a, b) + ); + const evaluations = msg.value[2]; + const canViewEvaluations = + canViewSWUOpportunityTeamQuestionResponseEvaluations(opportunity); + return [ + state + .set("opportunity", opportunity) + .set("evaluations", evaluations) + .set("proposals", proposals) + .set("canViewEvaluations", canViewEvaluations) + // Determine whether the "Submit" button should be shown at all. + // Can be submitted if... + // - Opportunity has the appropriate status; and + // - All questions have been evaluated. + .set( + "canEvaluationsBeSubmitted", + opportunity.status === + SWUOpportunityStatus.EvaluationTeamQuestionsPanel && + evaluations.reduce( + (acc, e) => + acc || + canSWUTeamQuestionResponseEvaluationBeSubmitted( + e, + opportunity + ), + false as boolean + ) + ), + [component_.cmd.dispatch(component_.page.readyMsg())] + ]; + } + + case "submit": { + const opportunity = state.opportunity; + if (!opportunity) return [state, []]; + return [ + state.set("submitLoading", true), + [ + component_.cmd.map( + component_.cmd.sequence( + state.evaluations.map(({ id }) => + api.evaluations.swu.update< + api.ResponseValidation< + SWUTeamQuestionResponseEvaluation, + UpdateValidationErrors + > + >()(id, adt("submit", ""), (response) => response) + ) + ), + (evaluationResponses) => + adt("onSubmitResponse", evaluationResponses) + ) + ] + ]; + } + + case "onSubmitResponse": { + const opportunity = state.opportunity; + if (!opportunity) return [state, []]; + state = state.set("submitLoading", false); + const result = msg.value; + if (result.some((e) => e.tag === "valid" || e.tag === "unhandled")) { + return [ + state, + [ + component_.cmd.dispatch( + component_.global.showToastMsg( + adt( + "error", + toasts.statusChanged.error(SWUOpportunityStatus.Awarded) + ) + ) + ) + ] + ]; + } + return [ + state, + [ + component_.cmd.dispatch( + component_.global.showToastMsg( + adt( + "success", + toasts.statusChanged.success(SWUOpportunityStatus.Awarded) + ) + ) + ), + component_.cmd.join3( + api.opportunities.swu.readOne()(opportunity.id, (response) => + api.getValidValue(response, opportunity) + ), + api.proposals.swu.readMany(opportunity.id)((response) => + api.getValidValue(response, state.proposals) + ), + api.evaluations.swu.readMany(opportunity.id)((response) => + api.getValidValue(response, state.evaluations) + ), + (newOpp, newProposals, newEvaluations) => + adt("onInitResponse", [ + newOpp, + newProposals, + newEvaluations + ]) as Msg + ) + ] + ]; + } + + case "table": + return component_.base.updateChild({ + state, + childStatePath: ["table"], + childUpdate: Table.update, + childMsg: msg.value, + mapChildMsg: (value) => ({ tag: "table", value }) + }); + + default: + return [state, []]; + } +}; + +const NotAvailable: component_.base.ComponentView = ({ state }) => { + const opportunity = state.opportunity; + if (!opportunity) return null; + if (isSWUOpportunityAcceptingProposals(opportunity)) { + return ( +
+ Evaluations will be displayed here once this opportunity has closed. +
+ ); + } else { + return
No proposals were submitted to this opportunity.
; + } +}; + +interface ProponentCellProps { + proposal: SWUProposalSlim; + opportunity: SWUOpportunity; + disabled: boolean; +} + +const ProponentCell: component_.base.View = ({ + proposal, + opportunity, + disabled +}) => { + const proposalRouteParams = { + proposalId: proposal.id, + opportunityId: opportunity.id, + tab: "proposal" as const + }; + return ( +
+ + {getSWUProponentName(proposal)} + + {(() => { + if (!proposal.organization) { + return null; + } + return ( +
+ {proposal.anonymousProponentName} +
+ ); + })()} +
+ ); +}; + +function evaluationTableBodyRows(state: Immutable): Table.BodyRows { + const opportunity = state.opportunity; + if (!opportunity) return []; + const issubmitLoading = !!state.submitLoading; + const isLoading = issubmitLoading; + return state.proposals.map((p) => { + return [ + { + className: "text-wrap", + children: ( + + ) + }, + ...opportunity.teamQuestions.map((tq) => { + const evaluation = state.evaluations.find( + (e) => e.proposal.id === p.id + ); + const score = evaluation?.scores.find( + (s) => tq.order === s.order + )?.score; + return { + className: "text-center", + children: ( +
+ {score ? `${score.toFixed(NUM_SCORE_DECIMALS)}%` : EMPTY_STRING} +
+ ) + }; + }), + { + className: "text-center", + children: "PLACEHOLDER" + } + ]; + }); +} + +function evaluationTableHeadCells(state: Immutable): Table.HeadCells { + return [ + { + children: "Participant", + className: "text-nowrap", + style: { width: "100%", minWidth: "200px" } + }, + ...(state.opportunity?.teamQuestions.map((tq) => ({ + children: `Q${tq.order + 1}`, + className: "text-nowrap text-center", + style: { width: "0px" } + })) ?? []), + { + children: "Action", + className: "text-nowrap text-center", + style: { width: "0px" } + } + ]; +} + +const ProponentEvaluations: component_.base.ComponentView = ({ + state, + dispatch +}) => { + return ( + + adt("table" as const, msg) + )} + /> + ); +}; + +const makeCardData = ( + opportunity: SWUOpportunity, + proposals: SWUProposalSlim[] +): ReportCard[] => { + const numProposals = opportunity.reporting?.numProposals || 0; + const [highestScore, averageScore] = proposals.reduce( + ([highest, average], { totalScore }, i) => { + if (!totalScore) { + return [highest, average]; + } + return [ + totalScore > highest ? totalScore : highest, + (average * i + totalScore) / (i + 1) + ]; + }, + [0, 0] + ); + const isAwarded = opportunity.status === SWUOpportunityStatus.Awarded; + return [ + { + icon: "comment-dollar", + name: `Proposal${numProposals === 1 ? "" : "s"}`, + value: numProposals ? String(numProposals) : EMPTY_STRING + }, + { + icon: "star-full", + iconColor: "c-report-card-icon-highlight", + name: "Top Score", + value: + isAwarded && highestScore + ? `${highestScore.toFixed(NUM_SCORE_DECIMALS)}%` + : EMPTY_STRING + }, + { + icon: "star-half", + iconColor: "c-report-card-icon-highlight", + name: "Avg. Score", + value: + isAwarded && averageScore + ? `${averageScore.toFixed(NUM_SCORE_DECIMALS)}%` + : EMPTY_STRING + } + ]; +}; + +const view: component_.page.View = (props) => { + const { state } = props; + const opportunity = state.opportunity; + if (!opportunity) return null; + const cardData = makeCardData(opportunity, state.proposals); + return ( +
+ + + + + + +
+ + +

Overview

+ + + {state.canViewEvaluations && state.proposals.length ? ( + + ) : ( + + )} + +
+
+
+ ); +}; + +export const component: Tab.Component = { + init, + update, + view, + + onInitResponse(response) { + return adt("onInitResponse", response); + } +}; From 10c792eed8701ed40405de2ee30e3fe617363f66 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Tue, 24 Sep 2024 13:45:39 -0700 Subject: [PATCH 38/58] refactor: provide multiple evaluations through query params --- src/front-end/typescript/lib/app/router.ts | 51 +++++++++-- .../proposal/sprint-with-us/view/index.tsx | 87 +++++++++++++++++-- .../proposal/sprint-with-us/view/tab/index.ts | 3 + 3 files changed, 125 insertions(+), 16 deletions(-) diff --git a/src/front-end/typescript/lib/app/router.ts b/src/front-end/typescript/lib/app/router.ts index da1190294..bc781abde 100644 --- a/src/front-end/typescript/lib/app/router.ts +++ b/src/front-end/typescript/lib/app/router.ts @@ -14,7 +14,7 @@ import * as SWUProposalEditTab from "front-end/lib/pages/proposal/sprint-with-us import * as SWUProposalViewTab from "front-end/lib/pages/proposal/sprint-with-us/view/tab"; import * as TWUProposalEditTab from "front-end/lib/pages/proposal/team-with-us/edit/tab"; import * as UserProfileTab from "front-end/lib/pages/user/profile/tab"; -import { getString } from "shared/lib"; +import { getString, getStringArray } from "shared/lib"; import { adt } from "shared/lib/types"; export function pushState(route: Route, msg: Msg): component.Cmd { @@ -123,12 +123,26 @@ const router: router_.Router = { "/opportunities/sprint-with-us/:opportunityId/proposals/:proposalId" ), makeRoute({ params, query }) { + const qEvalIndividual = Array.isArray(query.qEvalIndividual) + ? getStringArray(query, "qEvalIndividual") + : [getString(query, "qEvalIndividual")].filter(Boolean); + const qEvalConsensus = getString(query, "qEvalConsensus"); + const qEvalMode = + query.qEvalMode === "create" + ? "create" + : query.qEvalMode === "edit" + ? "edit" + : undefined; + return { tag: "proposalSWUView", value: { proposalId: params.proposalId || "", opportunityId: params.opportunityId || "", - tab: SWUProposalViewTab.parseTabId(query.tab) || undefined + tab: SWUProposalViewTab.parseTabId(query.tab) || undefined, + qEvalIndividual, + qEvalConsensus, + qEvalMode } }; } @@ -716,14 +730,35 @@ const router: router_.Router = { route.value.tab ? `?tab=${route.value.tab}` : "" }` ); - case "proposalSWUView": + case "proposalSWUView": { + const params = new URLSearchParams({ + ...(route.value.tab ? { tab: route.value.tab } : {}) + }); + switch (route.value.qEvalMode) { + case "create": + params.append("qEvalMode", "create"); + break; + case "edit": { + params.append("qEvalMode", "edit"); + route.value.qEvalIndividual?.forEach((q) => { + params.append("qEvalIndividual", q); + }); + if (route.value.qEvalConsensus) { + params.append("qEvalConsensus", route.value.qEvalConsensus); + } + break; + } + } + return prefixPath( - `/opportunities/sprint-with-us/${ - route.value.opportunityId - }/proposals/${route.value.proposalId}${ - route.value.tab ? `?tab=${route.value.tab}` : "" - }` + [ + `/opportunities/sprint-with-us/${route.value.opportunityId}/proposals/${route.value.proposalId}`, + params.toString() + ] + .filter(Boolean) + .join("?") ); + } case "proposalSWUExportOne": return prefixPath( `/opportunities/sprint-with-us/${route.value.opportunityId}/proposals/${route.value.proposalId}/export` diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx index f8cbc837f..0190b7441 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx @@ -26,6 +26,7 @@ import { UserType, User } from "shared/lib/resources/user"; import { adt, ADT, Id } from "shared/lib/types"; import { invalid, valid, Validation } from "shared/lib/validation"; import { SWUOpportunity } from "shared/lib/resources/opportunity/sprint-with-us"; +import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; interface ValidState extends Tab.ParentState { proposal: SWUProposal | null; @@ -40,7 +41,16 @@ export type State = State_; export type InnerMsg_ = Tab.ParentInnerMsg< K, - ADT<"onInitResponse", [User, RouteParams, SWUProposal, SWUOpportunity]> + ADT< + "onInitResponse", + [ + User, + RouteParams, + SWUProposal, + SWUOpportunity, + SWUTeamQuestionResponseEvaluation[] + ] + > >; export type InnerMsg = InnerMsg_; @@ -49,12 +59,34 @@ export type Msg_ = Tab.ParentMsg; export type Msg = Msg_; -export interface RouteParams { +interface CreateEvaluationRouteParams { opportunityId: Id; proposalId: Id; tab?: Tab.TabId; + qEvalMode: "create"; } +interface EditEvaluationRouteParams { + opportunityId: Id; + proposalId: Id; + tab?: Tab.TabId; + qEvalIndividual?: Id[]; + qEvalConsensus?: Id; + qEvalMode: "edit"; +} + +interface DefaultRouteParams { + opportunityId: Id; + proposalId: Id; + tab?: Tab.TabId; + qEvalMode?: undefined; +} + +export type RouteParams = + | DefaultRouteParams + | CreateEvaluationRouteParams + | EditEvaluationRouteParams; + function makeInit(): component_.page.Init< RouteParams, SharedState, @@ -75,14 +107,45 @@ function makeInit(): component_.page.Init< }) ) as State_, [ - component_.cmd.join( + component_.cmd.join3( api.proposals.swu.readOne(opportunityId)(proposalId, (response) => api.isValid(response) ? response.value : null ), api.opportunities.swu.readOne()(opportunityId, (response) => api.isValid(response) ? response.value : null ), - (proposal, opportunity) => { + routeParams.qEvalMode === "edit" + ? component_.cmd.map( + component_.cmd.sequence( + [ + ...(routeParams.qEvalIndividual ?? []), + ...(routeParams.qEvalConsensus + ? [routeParams.qEvalConsensus] + : []) + ].map((id) => + api.evaluations.swu.readOne< + api.ResponseValidation< + SWUTeamQuestionResponseEvaluation, + string[] + > + >()(id, (a) => a) + ) + ), + (questionEvaluationResponses) => { + return questionEvaluationResponses.reduce< + SWUTeamQuestionResponseEvaluation[] + >((questionEvaluations, questionEvaluationResponse) => { + return api.isValid(questionEvaluationResponse) + ? [ + ...questionEvaluations, + questionEvaluationResponse.value + ] + : questionEvaluations; + }, []); + } + ) + : component_.cmd.dispatch([]), + (proposal, opportunity, questionEvaluations) => { if (!proposal || !opportunity) return component_.global.replaceRouteMsg( adt("notFound" as const, { path: routePath }) @@ -91,7 +154,8 @@ function makeInit(): component_.page.Init< shared.sessionUser, routeParams, proposal, - opportunity + opportunity, + questionEvaluations ]) as Msg; } ) @@ -139,8 +203,13 @@ function makeComponent(): component_.page.Component< extraUpdate: ({ state, msg }) => { switch (msg.tag) { case "onInitResponse": { - const [viewerUser, routeParams, proposal, opportunity] = - msg.value; + const [ + viewerUser, + routeParams, + proposal, + opportunity, + questionEvaluations + ] = msg.value; // Set up the visible tab state. const tabId = routeParams.tab || "proposal"; // Initialize the sidebar. @@ -153,7 +222,9 @@ function makeComponent(): component_.page.Component< const [tabState, tabCmds] = tabComponent.init({ viewerUser, proposal, - opportunity + opportunity, + questionEvaluations, + questionEvaluationMode: routeParams.qEvalMode }); // Everything checks out, return valid state. return [ diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts index 7f8dfb806..3f6ab3f11 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts @@ -9,6 +9,7 @@ import * as TeamScenarioTab from "front-end/lib/pages/proposal/sprint-with-us/vi import { routeDest } from "front-end/lib/views/link"; import { SWUOpportunity } from "shared/lib/resources/opportunity/sprint-with-us"; import { SWUProposal } from "shared/lib/resources/proposal/sprint-with-us"; +import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; import { User } from "shared/lib/resources/user"; import { adt } from "shared/lib/types"; @@ -33,6 +34,8 @@ export interface Params { proposal: SWUProposal; opportunity: SWUOpportunity; viewerUser: User; + questionEvaluations: SWUTeamQuestionResponseEvaluation[]; + questionEvaluationMode?: "create" | "edit"; } export type InitResponse = null; From 7046634748479b7ab24f53704c33c8f16d98a49c Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Thu, 26 Sep 2024 12:14:03 -0700 Subject: [PATCH 39/58] refactor: use opportunity questions to validate --- .../question-evaluation/sprint-with-us.ts | 24 +++++++++----- .../question-evaluation/sprint-with-us.ts | 31 ++++++++++--------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts index 2d1c5f5eb..72b9af4ee 100644 --- a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts @@ -273,10 +273,16 @@ const create: crud.Create< }); } + const fullOpportunity = await db.readOneSWUOpportunity( + connection, + validatedSWUProposal.value.opportunity.id, + request.session + ); + const validatedScores = questionEvaluationValidation.validateSWUTeamQuestionResponseEvaluationScores( scores, - validatedSWUProposal.value.teamQuestionResponses + fullOpportunity.value?.teamQuestions ?? [] ); if (isValid(validatedScores)) { @@ -406,16 +412,17 @@ const update: crud.Update< }); } - const fullProposal = await db.readOneSWUProposal( + const fullOpportunity = await db.readOneSWUOpportunity( connection, - validatedSWUTeamQuestionResponseEvaluation.value.proposal.id, + validatedSWUTeamQuestionResponseEvaluation.value.proposal + .opportunity.id, request.session ); const validatedScores = questionEvaluationValidation.validateSWUTeamQuestionResponseEvaluationScores( scores, - fullProposal.value?.teamQuestionResponses ?? [] + fullOpportunity.value?.teamQuestions ?? [] ); if (isValid(validatedScores)) { @@ -448,16 +455,17 @@ const update: crud.Update< }); } - const fullProposal = await db.readOneSWUProposal( + const fullOpportunity = await db.readOneSWUOpportunity( connection, - validatedSWUTeamQuestionResponseEvaluation.value.proposal.id, + validatedSWUTeamQuestionResponseEvaluation.value.proposal + .opportunity.id, request.session ); const validatedScores = questionEvaluationValidation.validateSWUTeamQuestionResponseEvaluationScores( validatedSWUTeamQuestionResponseEvaluation.value.scores, - fullProposal.value?.teamQuestionResponses ?? [] + fullOpportunity.value?.teamQuestions ?? [] ); if ( @@ -465,7 +473,7 @@ const update: crud.Update< CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors[] >(validatedScores) || validatedScores.value.length !== - fullProposal.value?.teamQuestionResponses.length + fullOpportunity.value?.teamQuestions.length ) { return invalid({ evaluation: adt("submit" as const, [ diff --git a/src/shared/lib/validation/question-evaluation/sprint-with-us.ts b/src/shared/lib/validation/question-evaluation/sprint-with-us.ts index c77ece8c1..3ec9431bb 100644 --- a/src/shared/lib/validation/question-evaluation/sprint-with-us.ts +++ b/src/shared/lib/validation/question-evaluation/sprint-with-us.ts @@ -1,6 +1,6 @@ import { isArray } from "lodash"; import { getNumber, getString } from "shared/lib"; -import { SWUProposalTeamQuestionResponse } from "shared/lib/resources/proposal/sprint-with-us"; +import { SWUTeamQuestion } from "shared/lib/resources/opportunity/sprint-with-us"; import { CreateSWUTeamQuestionResponseEvaluationScoreBody, CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors, @@ -19,6 +19,7 @@ import { validateGenericString, validateGenericStringWords, validateNumber, + validateNumberWithPrecision, Validation } from "shared/lib/validation"; @@ -59,15 +60,16 @@ export const validateSWUTeamQuestionResponseEvaluationStatus = export function validateSWUTeamQuestionResponseEvaluationScoreOrder( raw: number, - proposalTeamQuestionResponses: SWUProposalTeamQuestionResponse[] + opportunityTeamQuestions: SWUTeamQuestion[] ): Validation { - return validateNumber(raw, 0, proposalTeamQuestionResponses.length, "Order"); + return validateNumber(raw, 0, opportunityTeamQuestions.length, "Order"); } export function validateSWUTeamQuestionResponseEvaluationScoreScore( - raw: number + raw: number, + maxScore: number ): Validation { - return validateNumber(raw, 1, undefined, "Score"); + return validateNumberWithPrecision(raw, 0, maxScore, 2, "Score"); } export function validateSWUTeamQuestionResponseEvaluationScoreNotes( @@ -78,30 +80,31 @@ export function validateSWUTeamQuestionResponseEvaluationScoreNotes( export function validateSWUTeamQuestionResponseEvaluationScore( raw: any, - proposalTeamQuestionResponses: SWUProposalTeamQuestionResponse[] + opportunityTeamQuestions: SWUTeamQuestion[] ): Validation< CreateSWUTeamQuestionResponseEvaluationScoreBody, CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors > { const validatedOrder = validateSWUTeamQuestionResponseEvaluationScoreOrder( getNumber(raw, "order"), - proposalTeamQuestionResponses + opportunityTeamQuestions ); if (isInvalid(validatedOrder)) { return invalid({ order: getInvalidValue(validatedOrder, undefined) }); } - const proposalTeamQuestionResponse = proposalTeamQuestionResponses.find( - (q) => q.order === validatedOrder.value - ); - if (!proposalTeamQuestionResponse) { + const maxScore = + opportunityTeamQuestions.find((q) => q.order === validatedOrder.value) + ?.score || null; + if (!maxScore) { return invalid({ order: ["No matching proposal team question response."] }); } const validatedScore = validateSWUTeamQuestionResponseEvaluationScoreScore( - getNumber(raw, "score") + getNumber(raw, "score"), + maxScore ); if (isInvalid(validatedScore)) { return invalid({ @@ -126,7 +129,7 @@ export function validateSWUTeamQuestionResponseEvaluationScore( export function validateSWUTeamQuestionResponseEvaluationScores( raw: any, - proposalTeamQuestionResponses: SWUProposalTeamQuestionResponse[] + opportunityTeamQuestions: SWUTeamQuestion[] ): ArrayValidation< CreateSWUTeamQuestionResponseEvaluationScoreBody, CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors @@ -145,7 +148,7 @@ export function validateSWUTeamQuestionResponseEvaluationScores( (v) => validateSWUTeamQuestionResponseEvaluationScore( v, - proposalTeamQuestionResponses + opportunityTeamQuestions ), {} ); From 4873c11fd7c89331ff6bdc82ae7dbd9298273991 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Thu, 26 Sep 2024 15:27:23 -0700 Subject: [PATCH 40/58] Revert "refactor: provide multiple evaluations through query params" This reverts commit 10c792eed8701ed40405de2ee30e3fe617363f66. --- src/front-end/typescript/lib/app/router.ts | 51 ++--------- .../proposal/sprint-with-us/view/index.tsx | 87 ++----------------- .../proposal/sprint-with-us/view/tab/index.ts | 3 - 3 files changed, 16 insertions(+), 125 deletions(-) diff --git a/src/front-end/typescript/lib/app/router.ts b/src/front-end/typescript/lib/app/router.ts index bc781abde..da1190294 100644 --- a/src/front-end/typescript/lib/app/router.ts +++ b/src/front-end/typescript/lib/app/router.ts @@ -14,7 +14,7 @@ import * as SWUProposalEditTab from "front-end/lib/pages/proposal/sprint-with-us import * as SWUProposalViewTab from "front-end/lib/pages/proposal/sprint-with-us/view/tab"; import * as TWUProposalEditTab from "front-end/lib/pages/proposal/team-with-us/edit/tab"; import * as UserProfileTab from "front-end/lib/pages/user/profile/tab"; -import { getString, getStringArray } from "shared/lib"; +import { getString } from "shared/lib"; import { adt } from "shared/lib/types"; export function pushState(route: Route, msg: Msg): component.Cmd { @@ -123,26 +123,12 @@ const router: router_.Router = { "/opportunities/sprint-with-us/:opportunityId/proposals/:proposalId" ), makeRoute({ params, query }) { - const qEvalIndividual = Array.isArray(query.qEvalIndividual) - ? getStringArray(query, "qEvalIndividual") - : [getString(query, "qEvalIndividual")].filter(Boolean); - const qEvalConsensus = getString(query, "qEvalConsensus"); - const qEvalMode = - query.qEvalMode === "create" - ? "create" - : query.qEvalMode === "edit" - ? "edit" - : undefined; - return { tag: "proposalSWUView", value: { proposalId: params.proposalId || "", opportunityId: params.opportunityId || "", - tab: SWUProposalViewTab.parseTabId(query.tab) || undefined, - qEvalIndividual, - qEvalConsensus, - qEvalMode + tab: SWUProposalViewTab.parseTabId(query.tab) || undefined } }; } @@ -730,35 +716,14 @@ const router: router_.Router = { route.value.tab ? `?tab=${route.value.tab}` : "" }` ); - case "proposalSWUView": { - const params = new URLSearchParams({ - ...(route.value.tab ? { tab: route.value.tab } : {}) - }); - switch (route.value.qEvalMode) { - case "create": - params.append("qEvalMode", "create"); - break; - case "edit": { - params.append("qEvalMode", "edit"); - route.value.qEvalIndividual?.forEach((q) => { - params.append("qEvalIndividual", q); - }); - if (route.value.qEvalConsensus) { - params.append("qEvalConsensus", route.value.qEvalConsensus); - } - break; - } - } - + case "proposalSWUView": return prefixPath( - [ - `/opportunities/sprint-with-us/${route.value.opportunityId}/proposals/${route.value.proposalId}`, - params.toString() - ] - .filter(Boolean) - .join("?") + `/opportunities/sprint-with-us/${ + route.value.opportunityId + }/proposals/${route.value.proposalId}${ + route.value.tab ? `?tab=${route.value.tab}` : "" + }` ); - } case "proposalSWUExportOne": return prefixPath( `/opportunities/sprint-with-us/${route.value.opportunityId}/proposals/${route.value.proposalId}/export` diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx index 0190b7441..f8cbc837f 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx @@ -26,7 +26,6 @@ import { UserType, User } from "shared/lib/resources/user"; import { adt, ADT, Id } from "shared/lib/types"; import { invalid, valid, Validation } from "shared/lib/validation"; import { SWUOpportunity } from "shared/lib/resources/opportunity/sprint-with-us"; -import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; interface ValidState extends Tab.ParentState { proposal: SWUProposal | null; @@ -41,16 +40,7 @@ export type State = State_; export type InnerMsg_ = Tab.ParentInnerMsg< K, - ADT< - "onInitResponse", - [ - User, - RouteParams, - SWUProposal, - SWUOpportunity, - SWUTeamQuestionResponseEvaluation[] - ] - > + ADT<"onInitResponse", [User, RouteParams, SWUProposal, SWUOpportunity]> >; export type InnerMsg = InnerMsg_; @@ -59,34 +49,12 @@ export type Msg_ = Tab.ParentMsg; export type Msg = Msg_; -interface CreateEvaluationRouteParams { +export interface RouteParams { opportunityId: Id; proposalId: Id; tab?: Tab.TabId; - qEvalMode: "create"; } -interface EditEvaluationRouteParams { - opportunityId: Id; - proposalId: Id; - tab?: Tab.TabId; - qEvalIndividual?: Id[]; - qEvalConsensus?: Id; - qEvalMode: "edit"; -} - -interface DefaultRouteParams { - opportunityId: Id; - proposalId: Id; - tab?: Tab.TabId; - qEvalMode?: undefined; -} - -export type RouteParams = - | DefaultRouteParams - | CreateEvaluationRouteParams - | EditEvaluationRouteParams; - function makeInit(): component_.page.Init< RouteParams, SharedState, @@ -107,45 +75,14 @@ function makeInit(): component_.page.Init< }) ) as State_, [ - component_.cmd.join3( + component_.cmd.join( api.proposals.swu.readOne(opportunityId)(proposalId, (response) => api.isValid(response) ? response.value : null ), api.opportunities.swu.readOne()(opportunityId, (response) => api.isValid(response) ? response.value : null ), - routeParams.qEvalMode === "edit" - ? component_.cmd.map( - component_.cmd.sequence( - [ - ...(routeParams.qEvalIndividual ?? []), - ...(routeParams.qEvalConsensus - ? [routeParams.qEvalConsensus] - : []) - ].map((id) => - api.evaluations.swu.readOne< - api.ResponseValidation< - SWUTeamQuestionResponseEvaluation, - string[] - > - >()(id, (a) => a) - ) - ), - (questionEvaluationResponses) => { - return questionEvaluationResponses.reduce< - SWUTeamQuestionResponseEvaluation[] - >((questionEvaluations, questionEvaluationResponse) => { - return api.isValid(questionEvaluationResponse) - ? [ - ...questionEvaluations, - questionEvaluationResponse.value - ] - : questionEvaluations; - }, []); - } - ) - : component_.cmd.dispatch([]), - (proposal, opportunity, questionEvaluations) => { + (proposal, opportunity) => { if (!proposal || !opportunity) return component_.global.replaceRouteMsg( adt("notFound" as const, { path: routePath }) @@ -154,8 +91,7 @@ function makeInit(): component_.page.Init< shared.sessionUser, routeParams, proposal, - opportunity, - questionEvaluations + opportunity ]) as Msg; } ) @@ -203,13 +139,8 @@ function makeComponent(): component_.page.Component< extraUpdate: ({ state, msg }) => { switch (msg.tag) { case "onInitResponse": { - const [ - viewerUser, - routeParams, - proposal, - opportunity, - questionEvaluations - ] = msg.value; + const [viewerUser, routeParams, proposal, opportunity] = + msg.value; // Set up the visible tab state. const tabId = routeParams.tab || "proposal"; // Initialize the sidebar. @@ -222,9 +153,7 @@ function makeComponent(): component_.page.Component< const [tabState, tabCmds] = tabComponent.init({ viewerUser, proposal, - opportunity, - questionEvaluations, - questionEvaluationMode: routeParams.qEvalMode + opportunity }); // Everything checks out, return valid state. return [ diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts index 3f6ab3f11..7f8dfb806 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts @@ -9,7 +9,6 @@ import * as TeamScenarioTab from "front-end/lib/pages/proposal/sprint-with-us/vi import { routeDest } from "front-end/lib/views/link"; import { SWUOpportunity } from "shared/lib/resources/opportunity/sprint-with-us"; import { SWUProposal } from "shared/lib/resources/proposal/sprint-with-us"; -import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; import { User } from "shared/lib/resources/user"; import { adt } from "shared/lib/types"; @@ -34,8 +33,6 @@ export interface Params { proposal: SWUProposal; opportunity: SWUOpportunity; viewerUser: User; - questionEvaluations: SWUTeamQuestionResponseEvaluation[]; - questionEvaluationMode?: "create" | "edit"; } export type InitResponse = null; From 1a8d45451b7872d98a101ccec2140c009a522935 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 9 Oct 2024 16:30:52 -0700 Subject: [PATCH 41/58] feat: evaluation initializers pages --- .../sprint-with-us/create-consensus.tsx | 84 ++++++++++++++++ .../sprint-with-us/create-individual.tsx | 81 +++++++++++++++ .../sprint-with-us/edit-consensus.tsx | 98 +++++++++++++++++++ .../sprint-with-us/edit-individual.tsx | 85 ++++++++++++++++ 4 files changed, 348 insertions(+) create mode 100644 src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx create mode 100644 src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx create mode 100644 src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx create mode 100644 src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx new file mode 100644 index 000000000..108f4f036 --- /dev/null +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx @@ -0,0 +1,84 @@ +import { Route, SharedState } from "front-end/lib/app/types"; +import { immutable, component as component_ } from "front-end/lib/framework"; +import * as api from "front-end/lib/http/api"; +import { adt, Id } from "shared/lib/types"; +import { invalid, valid } from "shared/lib/validation"; +import * as Tab from "front-end/lib/pages/proposal/sprint-with-us/view/tab"; +import { + component as proposalComponent, + State_, + InnerMsg_, + Msg +} from "front-end/lib/pages/proposal/sprint-with-us/view"; +import { isUserType } from "front-end/lib/access-control"; +import { UserType } from "shared/lib/resources/user"; + +export type RouteParams = { + opportunityId: Id; + proposalId: Id; + tab?: Tab.TabId; +}; + +function makeInit(): component_.page.Init< + RouteParams, + SharedState, + State_, + InnerMsg_, + Route +> { + return isUserType({ + userType: [UserType.Admin, UserType.Government], + success({ routePath, routeParams, shared }) { + const { opportunityId, proposalId } = routeParams; + return [ + valid( + immutable({ + proposal: null, + tab: null, + sidebar: null + }) + ) as State_, + [ + component_.cmd.join3( + api.proposals.swu.readOne(opportunityId)(proposalId, (response) => + api.isValid(response) ? response.value : null + ), + api.opportunities.swu.readOne()(opportunityId, (response) => + api.isValid(response) ? response.value : null + ), + api.evaluations.swu.readMany({ proposalId })((response) => + api.isValid(response) ? response.value : null + ), + (proposal, opportunity, individualEvaluations) => { + if (!proposal || !opportunity || !individualEvaluations) + return component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ); + return adt("onInitResponse", [ + shared.sessionUser, + routeParams, + proposal, + opportunity, + individualEvaluations + ]) as Msg; + } + ) + ] + ]; + }, + fail({ routePath }) { + return [ + invalid(null), + [ + component_.cmd.dispatch( + component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ) + ) + ] + ]; + } + }); +} + +export const component = { ...proposalComponent, init: makeInit() }; diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx new file mode 100644 index 000000000..95ef527ab --- /dev/null +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx @@ -0,0 +1,81 @@ +import { Route, SharedState } from "front-end/lib/app/types"; +import { immutable, component as component_ } from "front-end/lib/framework"; +import * as api from "front-end/lib/http/api"; +import { adt, Id } from "shared/lib/types"; +import { invalid, valid } from "shared/lib/validation"; +import * as Tab from "front-end/lib/pages/proposal/sprint-with-us/view/tab"; +import { + component as proposalComponent, + State_, + InnerMsg_, + Msg +} from "front-end/lib/pages/proposal/sprint-with-us/view"; +import { isUserType } from "front-end/lib/access-control"; +import { UserType } from "shared/lib/resources/user"; + +export type RouteParams = { + opportunityId: Id; + proposalId: Id; + tab?: Tab.TabId; +}; + +function makeInit(): component_.page.Init< + RouteParams, + SharedState, + State_, + InnerMsg_, + Route +> { + return isUserType({ + userType: [UserType.Admin, UserType.Government], + success({ routePath, routeParams, shared }) { + const { opportunityId, proposalId } = routeParams; + return [ + valid( + immutable({ + proposal: null, + tab: null, + sidebar: null + }) + ) as State_, + [ + component_.cmd.join( + api.proposals.swu.readOne(opportunityId)(proposalId, (response) => + api.isValid(response) ? response.value : null + ), + api.opportunities.swu.readOne()(opportunityId, (response) => + api.isValid(response) ? response.value : null + ), + (proposal, opportunity) => { + if (!proposal || !opportunity) + return component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ); + return adt("onInitResponse", [ + shared.sessionUser, + routeParams, + proposal, + opportunity, + [] // No evaluations to load + ]) as Msg; + } + ) + ] + ]; + }, + fail({ routePath }) { + return [ + invalid(null), + [ + component_.cmd.dispatch( + component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ) + ) + ] + ]; + } + }); +} + +export const component = { ...proposalComponent, init: makeInit() }; diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx new file mode 100644 index 000000000..9a9167ebd --- /dev/null +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx @@ -0,0 +1,98 @@ +import { Route, SharedState } from "front-end/lib/app/types"; +import { immutable, component as component_ } from "front-end/lib/framework"; +import * as api from "front-end/lib/http/api"; +import { adt, Id } from "shared/lib/types"; +import { invalid, valid } from "shared/lib/validation"; +import * as Tab from "front-end/lib/pages/proposal/sprint-with-us/view/tab"; +import { + component as proposalComponent, + State_, + InnerMsg_, + Msg +} from "front-end/lib/pages/proposal/sprint-with-us/view"; +import { isUserType } from "front-end/lib/access-control"; +import { UserType } from "shared/lib/resources/user"; + +export type RouteParams = { + opportunityId: Id; + proposalId: Id; + evaluationId: Id; + tab?: Tab.TabId; +}; + +function makeInit(): component_.page.Init< + RouteParams, + SharedState, + State_, + InnerMsg_, + Route +> { + return isUserType({ + userType: [UserType.Admin, UserType.Government], + success({ routePath, routeParams, shared }) { + const { opportunityId, proposalId, evaluationId } = routeParams; + return [ + valid( + immutable({ + proposal: null, + tab: null, + sidebar: null + }) + ) as State_, + [ + component_.cmd.join4( + api.proposals.swu.readOne(opportunityId)(proposalId, (response) => + api.isValid(response) ? response.value : null + ), + api.opportunities.swu.readOne()(opportunityId, (response) => + api.isValid(response) ? response.value : null + ), + api.evaluations.swu.readOne()(evaluationId, (response) => + api.isValid(response) ? response.value : null + ), + api.evaluations.swu.readMany({ proposalId })((response) => + api.isValid(response) ? response.value : null + ), + (proposal, opportunity, consensus, individualEvaluations) => { + if ( + !proposal || + !opportunity || + !consensus || + !individualEvaluations + ) + return component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ); + return adt("onInitResponse", [ + shared.sessionUser, + routeParams, + proposal, + opportunity, + [ + consensus, + ...(Array.isArray(individualEvaluations) + ? individualEvaluations + : []) + ] + ]) as Msg; + } + ) + ] + ]; + }, + fail({ routePath }) { + return [ + invalid(null), + [ + component_.cmd.dispatch( + component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ) + ) + ] + ]; + } + }); +} + +export const component = { ...proposalComponent, init: makeInit() }; diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx new file mode 100644 index 000000000..b7a3c8bb5 --- /dev/null +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx @@ -0,0 +1,85 @@ +import { Route, SharedState } from "front-end/lib/app/types"; +import { immutable, component as component_ } from "front-end/lib/framework"; +import * as api from "front-end/lib/http/api"; +import { adt, Id } from "shared/lib/types"; +import { invalid, valid } from "shared/lib/validation"; +import * as Tab from "front-end/lib/pages/proposal/sprint-with-us/view/tab"; +import { + component as proposalComponent, + State_, + InnerMsg_, + Msg +} from "front-end/lib/pages/proposal/sprint-with-us/view"; +import { isUserType } from "front-end/lib/access-control"; +import { UserType } from "shared/lib/resources/user"; + +export type RouteParams = { + opportunityId: Id; + proposalId: Id; + evaluationId: Id; + tab?: Tab.TabId; +}; + +function makeInit(): component_.page.Init< + RouteParams, + SharedState, + State_, + InnerMsg_, + Route +> { + return isUserType({ + userType: [UserType.Admin, UserType.Government], + success({ routePath, routeParams, shared }) { + const { opportunityId, proposalId, evaluationId } = routeParams; + return [ + valid( + immutable({ + proposal: null, + tab: null, + sidebar: null + }) + ) as State_, + [ + component_.cmd.join3( + api.proposals.swu.readOne(opportunityId)(proposalId, (response) => + api.isValid(response) ? response.value : null + ), + api.opportunities.swu.readOne()(opportunityId, (response) => + api.isValid(response) ? response.value : null + ), + api.evaluations.swu.readOne()(evaluationId, (response) => + api.isValid(response) ? response.value : null + ), + (proposal, opportunity, evaluation) => { + if (!proposal || !opportunity || !evaluation) + return component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ); + return adt("onInitResponse", [ + shared.sessionUser, + routeParams, + proposal, + opportunity, + [evaluation] + ]) as Msg; + } + ) + ] + ]; + }, + fail({ routePath }) { + return [ + invalid(null), + [ + component_.cmd.dispatch( + component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ) + ) + ] + ]; + } + }); +} + +export const component = { ...proposalComponent, init: makeInit() }; From 6dae86ac77f6d24e46c13d26a73b00f501f21552 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 9 Oct 2024 16:33:59 -0700 Subject: [PATCH 42/58] feat: add read individual evaluations permission --- src/back-end/lib/permissions.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/back-end/lib/permissions.ts b/src/back-end/lib/permissions.ts index b21f4c160..7866a2394 100644 --- a/src/back-end/lib/permissions.ts +++ b/src/back-end/lib/permissions.ts @@ -937,6 +937,31 @@ export async function readManySWUTeamQuestionResponseEvaluations( ); } +export async function readManyIndividualSWUTeamQuestionResponseEvaluationsForConsensus( + connection: Connection, + session: Session, + proposal: SWUProposal +): Promise { + return ( + !!session && + (isAdmin(session) || isGovernment(session)) && + (doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations( + proposal.opportunity.status + ) || + (proposal.status === SWUProposalStatus.TeamQuestionsPanelConsensus && + ((await isSWUOpportunityEvaluationPanelEvaluator( + connection, + session, + proposal.opportunity.id + )) || + (await isSWUOpportunityEvaluationPanelChair( + connection, + session, + proposal.opportunity.id + ))))) + ); +} + export function readOwnSWUTeamQuestionResponseEvaluations( session: Session ): boolean { From a2fdf4974ea9fcc50dddabb8b70b0cafc6fb256c Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 9 Oct 2024 16:38:52 -0700 Subject: [PATCH 43/58] feat: add read individual consensus evaluations db helper --- .../db/question-evaluation/sprint-with-us.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts index 07b85e83f..33a372f58 100644 --- a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts @@ -164,6 +164,51 @@ export const isSWUOpportunityEvaluationPanelEvaluator = export const isSWUOpportunityEvaluationPanelChair = makeIsSWUOpportunityEvaluationPanelMember((epm) => epm.chair); +export const readManyIndividualSWUTeamQuestionResponseEvaluationsForConsensus = + tryDb<[AuthenticatedSession, Id], SWUTeamQuestionResponseEvaluation[]>( + async (connection, session, id) => { + const query = generateSWUTeamQuestionResponseEvaluationQuery( + connection + ).where({ + "evaluations.proposal": id, + "evaluations.type": SWUTeamQuestionResponseEvaluationType.Individual + }); + + const results = await Promise.all( + ( + await query + ).map(async (result: RawSWUTeamQuestionResponseEvaluation) => { + result.scores = + getValidValue( + await readManyTeamQuestionResponseEvaluationScores( + connection, + result.id + ), + [] + ) ?? []; + return result; + }) + ); + + if (!results) { + throw new Error("unable to read evaluations"); + } + + return valid( + await Promise.all( + results.map( + async (result) => + await rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation( + connection, + session, + result + ) + ) + ) + ); + } + ); + export const readManySWUTeamQuestionResponseEvaluations = tryDb< [AuthenticatedSession, Id, boolean], SWUTeamQuestionResponseEvaluationSlim[] From 402f441ea67e9703b352b7fe4536dbf1b47343a9 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 9 Oct 2024 16:41:38 -0700 Subject: [PATCH 44/58] refactor: remove slim question response evaluation type --- .../db/question-evaluation/sprint-with-us.ts | 40 ++----------- .../question-evaluation/sprint-with-us.ts | 38 ++++++++++++- .../api/question-evaluation/sprint-with-us.ts | 48 ++++++---------- .../opportunity/sprint-with-us/edit/index.tsx | 12 ++-- .../sprint-with-us/edit/tab/index.ts | 4 +- .../sprint-with-us/edit/tab/overview.tsx | 57 +++++++++++++++---- .../question-evaluation/sprint-with-us.ts | 35 +++++++++--- 7 files changed, 136 insertions(+), 98 deletions(-) diff --git a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts index 33a372f58..a008bf98e 100644 --- a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts @@ -21,7 +21,6 @@ import { CreateSWUTeamQuestionResponseEvaluationScoreBody, SWUTeamQuestionResponseEvaluation, SWUTeamQuestionResponseEvaluationScores, - SWUTeamQuestionResponseEvaluationSlim, SWUTeamQuestionResponseEvaluationStatus, SWUTeamQuestionResponseEvaluationType, UpdateEditRequestBody @@ -105,37 +104,6 @@ async function rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation }; } -async function rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluationSlim( - connection: Connection, - session: Session, - raw: RawSWUTeamQuestionResponseEvaluation -): Promise { - const { - proposal: proposalId, - evaluationPanelMember, - scores, - ...restOfRaw - } = raw; - - const proposal = - session && - getValidValue( - await readOneSWUProposalSlim(connection, proposalId, session), - null - ); - if (!proposal) { - throw new Error("unable to process team question response evaluation"); - } - - return { - ...restOfRaw, - proposal, - scores: scores.map( - ({ notes, teamQuestionResponseEvaluation, ...score }) => score - ) - }; -} - function makeIsSWUOpportunityEvaluationPanelMember( typeFn: (epm: SWUEvaluationPanelMember) => boolean ) { @@ -211,7 +179,7 @@ export const readManyIndividualSWUTeamQuestionResponseEvaluationsForConsensus = export const readManySWUTeamQuestionResponseEvaluations = tryDb< [AuthenticatedSession, Id, boolean], - SWUTeamQuestionResponseEvaluationSlim[] + SWUTeamQuestionResponseEvaluation[] >(async (connection, session, id, isConsensus) => { const query = generateSWUTeamQuestionResponseEvaluationQuery(connection) .join("swuProposals", "swuProposals.id", "=", "evaluations.proposal") @@ -254,7 +222,7 @@ export const readManySWUTeamQuestionResponseEvaluations = tryDb< await Promise.all( results.map( async (result) => - await rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluationSlim( + await rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation( connection, session, result @@ -266,7 +234,7 @@ export const readManySWUTeamQuestionResponseEvaluations = tryDb< export const readOwnSWUTeamQuestionResponseEvaluations = tryDb< [AuthenticatedSession], - SWUTeamQuestionResponseEvaluationSlim[] + SWUTeamQuestionResponseEvaluation[] >(async (connection, session) => { const evaluations = await generateSWUTeamQuestionResponseEvaluationQuery( connection @@ -283,7 +251,7 @@ export const readOwnSWUTeamQuestionResponseEvaluations = tryDb< await Promise.all( evaluations.map( async (result) => - await rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluationSlim( + await rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation( connection, session, result diff --git a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts index 72b9af4ee..12651f75a 100644 --- a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts @@ -19,7 +19,6 @@ import { CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors, CreateValidationErrors, SWUTeamQuestionResponseEvaluation, - SWUTeamQuestionResponseEvaluationSlim, SWUTeamQuestionResponseEvaluationStatus, SWUTeamQuestionResponseEvaluationType, CreateRequestBody as SharedCreateRequestBody, @@ -67,18 +66,51 @@ const readMany: crud.ReadMany = ( connection: db.Connection ) => { return nullRequestBodyHandler< - JsonResponseBody, + JsonResponseBody, Session >(async (request) => { const respond = ( code: number, - body: SWUTeamQuestionResponseEvaluationSlim[] | string[] + body: SWUTeamQuestionResponseEvaluation[] | string[] ) => basicResponse(code, request.session, makeJsonResponseBody(body)); if (request.query.opportunity) { if (!permissions.isSignedIn(request.session)) { return respond(401, [permissions.ERROR_MESSAGE]); } + const validatedSWUProposal = await validateSWUProposalId( + connection, + request.query.proposal, + request.session + ); + if (isInvalid(validatedSWUProposal)) { + return respond(404, ["Sprint With Us proposal not found."]); + } + + if ( + !(await permissions.readManyIndividualSWUTeamQuestionResponseEvaluationsForConsensus( + connection, + request.session, + validatedSWUProposal.value + )) + ) { + return respond(401, [permissions.ERROR_MESSAGE]); + } + const dbResult = + await db.readManyIndividualSWUTeamQuestionResponseEvaluationsForConsensus( + connection, + request.session, + request.query.proposal + ); + if (isInvalid(dbResult)) { + return respond(503, [db.ERROR_MESSAGE]); + } + return respond(200, dbResult.value); + } else if (request.query.proposal) { + if (!permissions.isSignedIn(request.session)) { + return respond(401, [permissions.ERROR_MESSAGE]); + } + const validatedSWUOpportunity = await validateSWUOpportunityId( connection, request.query.opportunity, diff --git a/src/front-end/typescript/lib/http/api/question-evaluation/sprint-with-us.ts b/src/front-end/typescript/lib/http/api/question-evaluation/sprint-with-us.ts index 0dfde7230..694b530db 100644 --- a/src/front-end/typescript/lib/http/api/question-evaluation/sprint-with-us.ts +++ b/src/front-end/typescript/lib/http/api/question-evaluation/sprint-with-us.ts @@ -23,11 +23,16 @@ export function create(): crud.CreateAction< * @param opportunityId * @param consensus */ -export function readMany( - opportunityId?: Id, - consensus?: boolean -): crud.ReadManyAction< - Resource.SWUTeamQuestionResponseEvaluationSlim, +export function readMany({ + opportunityId, + proposalId, + consensus +}: { + opportunityId?: Id; + proposalId?: Id; + consensus?: boolean; +} = {}): crud.ReadManyAction< + Resource.SWUTeamQuestionResponseEvaluation, string[], Msg > { @@ -35,29 +40,28 @@ export function readMany( opportunity: opportunityId !== undefined ? window.encodeURIComponent(opportunityId) - : "" + : "", + proposal: + proposalId !== undefined ? window.encodeURIComponent(proposalId) : "" }); if (consensus) { params.append("consensus", "true"); } return crud.makeReadManyAction( NAMESPACE, - rawSWUTeamQuestionResponseEvaluationSlimToSWUTeamQuestionResponseEvaluationSlim, + rawSWUTeamQuestionResponseEvaluationToSWUTeamQuestionResponseEvaluation, params.toString() ); } -export function readOne( - opportunityId: Id -): crud.ReadOneAction< +export function readOne(): crud.ReadOneAction< Resource.SWUTeamQuestionResponseEvaluation, string[], Msg > { return crud.makeReadOneAction( NAMESPACE, - rawSWUTeamQuestionResponseEvaluationToSWUTeamQuestionResponseEvaluation, - `opportunity=${window.encodeURIComponent(opportunityId)}` + rawSWUTeamQuestionResponseEvaluationToSWUTeamQuestionResponseEvaluation ); } @@ -94,23 +98,3 @@ function rawSWUTeamQuestionResponseEvaluationToSWUTeamQuestionResponseEvaluation scores: raw.scores.sort((a, b) => compareNumbers(a.order, b.order)) }; } - -interface RawSWUTeamQuestionResponseEvaluationSlim - extends Omit< - Resource.SWUTeamQuestionResponseEvaluationSlim, - "createdAt" | "updatedAt" - > { - createdAt: string; - updatedAt: string; -} - -function rawSWUTeamQuestionResponseEvaluationSlimToSWUTeamQuestionResponseEvaluationSlim( - raw: RawSWUTeamQuestionResponseEvaluationSlim -): Resource.SWUTeamQuestionResponseEvaluationSlim { - return { - ...raw, - createdAt: new Date(raw.createdAt), - updatedAt: new Date(raw.updatedAt), - scores: raw.scores.sort((a, b) => compareNumbers(a.order, b.order)) - }; -} diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/index.tsx b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/index.tsx index e717c98b4..fcf06751f 100644 --- a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/index.tsx +++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/index.tsx @@ -26,7 +26,7 @@ import { User, UserType, isAdmin } from "shared/lib/resources/user"; import { adt, ADT, Id } from "shared/lib/types"; import { invalid, valid, Validation } from "shared/lib/validation"; import { SWUProposalSlim } from "shared/lib/resources/proposal/sprint-with-us"; -import { SWUTeamQuestionResponseEvaluationSlim } from "shared/lib/resources/question-evaluation/sprint-with-us"; +import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; interface ValidState extends Tab.ParentState { opportunity: SWUOpportunity | null; @@ -48,7 +48,7 @@ export type InnerMsg_ = Tab.ParentInnerMsg< Tab.TabId, api.ResponseValidation, api.ResponseValidation, - api.ResponseValidation, + api.ResponseValidation, User ] > @@ -110,10 +110,10 @@ function makeInit(): component_.page.Init< ) : component_.cmd.dispatch(valid([])), Tab.shouldLoadEvaluationsForTab(tabId) - ? api.evaluations.swu.readMany( - routeParams.opportunityId, - tabId === "consensus" - )((response) => response) + ? api.evaluations.swu.readMany({ + opportunityId: routeParams.opportunityId, + consensus: tabId === "consensus" + })((response) => response) : component_.cmd.dispatch(valid([])), (opportunity, proposals, evaluations) => adt("onInitResponse", [ diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts index efb91fee0..280e1209b 100644 --- a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts +++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts @@ -21,7 +21,7 @@ import { User } from "shared/lib/resources/user"; import { adt, Id } from "shared/lib/types"; import { SWUProposalSlim } from "shared/lib/resources/proposal/sprint-with-us"; import { GUIDE_AUDIENCE } from "front-end/lib/pages/guide/view"; -import { SWUTeamQuestionResponseEvaluationSlim } from "shared/lib/resources/question-evaluation/sprint-with-us"; +import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; // Parent page types & functions. @@ -53,7 +53,7 @@ export type TabPermissions = { export type InitResponse = [ SWUOpportunity, SWUProposalSlim[], - SWUTeamQuestionResponseEvaluationSlim[] + SWUTeamQuestionResponseEvaluation[] ]; export type Component = TabbedPage.TabComponent< diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx index 49964cbe7..fc4ba4ba9 100644 --- a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx +++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx @@ -31,7 +31,6 @@ import { } from "shared/lib/resources/proposal/sprint-with-us"; import { SWUTeamQuestionResponseEvaluation, - SWUTeamQuestionResponseEvaluationSlim, canSWUTeamQuestionResponseEvaluationBeSubmitted } from "shared/lib/resources/question-evaluation/sprint-with-us"; import { ADT, adt } from "shared/lib/types"; @@ -41,7 +40,7 @@ export interface State extends Tab.Params { submitLoading: boolean; canEvaluationsBeSubmitted: boolean; canViewEvaluations: boolean; - evaluations: SWUTeamQuestionResponseEvaluationSlim[]; + evaluations: SWUTeamQuestionResponseEvaluation[]; proposals: SWUProposalSlim[]; table: Immutable; } @@ -53,7 +52,7 @@ export type InnerMsg = | ADT< "onSubmitResponse", api.ResponseValidation< - SWUTeamQuestionResponseEvaluationSlim, + SWUTeamQuestionResponseEvaluation, UpdateValidationErrors >[] >; @@ -182,8 +181,8 @@ const update: component_.page.Update = ({ api.proposals.swu.readMany(opportunity.id)((response) => api.getValidValue(response, state.proposals) ), - api.evaluations.swu.readMany(opportunity.id)((response) => - api.getValidValue(response, state.evaluations) + api.evaluations.swu.readMany({ opportunityId: opportunity.id })( + (response) => api.getValidValue(response, state.evaluations) ), (newOpp, newProposals, newEvaluations) => adt("onInitResponse", [ @@ -224,6 +223,38 @@ const NotAvailable: component_.base.ComponentView = ({ state }) => { } }; +const ContextMenuCell: component_.base.View<{ + disabled: boolean; + proposal: SWUProposalSlim; + evaluation?: SWUTeamQuestionResponseEvaluation; +}> = ({ disabled, proposal, evaluation }) => { + const proposalRouteParams = { + proposalId: proposal.id, + opportunityId: proposal.opportunity.id, + tab: "teamQuestions" as const + }; + return evaluation ? ( + + {evaluation ? "Edit" : "Start Evaluation"} + + ) : ( + + {evaluation ? "Edit" : "Start Evaluation"} + + ); +}; + interface ProponentCellProps { proposal: SWUProposalSlim; opportunity: SWUOpportunity; @@ -267,6 +298,7 @@ function evaluationTableBodyRows(state: Immutable): Table.BodyRows { const issubmitLoading = !!state.submitLoading; const isLoading = issubmitLoading; return state.proposals.map((p) => { + const evaluation = state.evaluations.find((e) => e.proposal.id === p.id); return [ { className: "text-wrap", @@ -279,12 +311,7 @@ function evaluationTableBodyRows(state: Immutable): Table.BodyRows { ) }, ...opportunity.teamQuestions.map((tq) => { - const evaluation = state.evaluations.find( - (e) => e.proposal.id === p.id - ); - const score = evaluation?.scores.find( - (s) => tq.order === s.order - )?.score; + const score = evaluation?.scores[tq.order].score; return { className: "text-center", children: ( @@ -296,7 +323,13 @@ function evaluationTableBodyRows(state: Immutable): Table.BodyRows { }), { className: "text-center", - children: "PLACEHOLDER" + children: ( + + ) } ]; }); diff --git a/src/shared/lib/resources/question-evaluation/sprint-with-us.ts b/src/shared/lib/resources/question-evaluation/sprint-with-us.ts index 394e39430..99b700149 100644 --- a/src/shared/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/shared/lib/resources/question-evaluation/sprint-with-us.ts @@ -1,6 +1,9 @@ import { ADT, BodyWithErrors, Id } from "shared/lib/types"; import { ErrorTypeFrom } from "shared/lib/validation"; -import { SWUEvaluationPanelMember } from "src/shared/lib/resources/opportunity/sprint-with-us"; +import { + SWUEvaluationPanelMember, + SWUOpportunity +} from "src/shared/lib/resources/opportunity/sprint-with-us"; import { SWUProposalSlim } from "src/shared/lib/resources/proposal/sprint-with-us"; export function parseSWUTeamQuestionResponseEvaluationType( @@ -56,12 +59,20 @@ export interface SWUTeamQuestionResponseEvaluation { updatedAt: Date; } -export interface SWUTeamQuestionResponseEvaluationSlim - extends Omit< - SWUTeamQuestionResponseEvaluation, - "proposal" | "evaluationPanelMember" | "scores" - > { - scores: Omit[]; +export function getEvaluationById( + evaluations: SWUTeamQuestionResponseEvaluation[], + id: Id +): SWUTeamQuestionResponseEvaluation | null { + return ( + evaluations.find((e) => e.evaluationPanelMember.user.id === id) ?? null + ); +} + +export function getEvaluationScoreByOrder( + evaluation: SWUTeamQuestionResponseEvaluation, + order: number +): SWUTeamQuestionResponseEvaluationScores | null { + return evaluation.scores.find((s) => s.order === order) ?? null; } // Create. @@ -127,3 +138,13 @@ export function isValidStatusChange( return false; } } + +export function canSWUTeamQuestionResponseEvaluationBeSubmitted( + e: Pick, + o: Pick +): boolean { + return ( + e.status === SWUTeamQuestionResponseEvaluationStatus.Draft && + o.teamQuestions.length === e.scores.length + ); +} From 0a4389e7515b0ef2bc0ef7abc6032b67b4642f33 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 9 Oct 2024 16:42:25 -0700 Subject: [PATCH 45/58] feat: connect question evaluation intializer pages --- src/front-end/typescript/lib/app/router.ts | 102 ++++++++++++++++++ src/front-end/typescript/lib/app/types.ts | 20 ++++ src/front-end/typescript/lib/app/update.ts | 48 +++++++++ .../typescript/lib/app/view/index.tsx | 4 + 4 files changed, 174 insertions(+) diff --git a/src/front-end/typescript/lib/app/router.ts b/src/front-end/typescript/lib/app/router.ts index da1190294..86b778f21 100644 --- a/src/front-end/typescript/lib/app/router.ts +++ b/src/front-end/typescript/lib/app/router.ts @@ -133,6 +133,68 @@ const router: router_.Router = { }; } }, + { + path: prefixPath( + "/opportunities/sprint-with-us/:opportunityId/proposals/:proposalId/question-evaluations/individual/create" + ), + makeRoute({ params, query }) { + return { + tag: "questionEvaluationIndividualSWUCreate", + value: { + proposalId: params.proposalId || "", + opportunityId: params.opportunityId || "", + tab: SWUProposalViewTab.parseTabId(query.tab) || undefined + } + }; + } + }, + { + path: prefixPath( + "/opportunities/sprint-with-us/:opportunityId/proposals/:proposalId/question-evaluations/individual/:evaluationId/edit" + ), + makeRoute({ params, query }) { + return { + tag: "questionEvaluationIndividualSWUEdit", + value: { + proposalId: params.proposalId || "", + opportunityId: params.opportunityId || "", + evaluationId: params.evaluationId || "", + tab: SWUProposalViewTab.parseTabId(query.tab) || undefined + } + }; + } + }, + { + path: prefixPath( + "/opportunities/sprint-with-us/:opportunityId/proposals/:proposalId/question-evaluations/consensus/create" + ), + makeRoute({ params, query }) { + return { + tag: "questionEvaluationConsensusSWUCreate", + value: { + proposalId: params.proposalId || "", + opportunityId: params.opportunityId || "", + tab: SWUProposalViewTab.parseTabId(query.tab) || undefined + } + }; + } + }, + { + path: prefixPath( + "/opportunities/sprint-with-us/:opportunityId/proposals/:proposalId/question-evaluations/consensus/:evaluationId/edit" + ), + makeRoute({ params, query }) { + return { + tag: "questionEvaluationConsensusSWUEdit", + value: { + proposalId: params.proposalId || "", + opportunityId: params.opportunityId || "", + evaluationId: params.evaluationId || "", + tab: SWUProposalViewTab.parseTabId(query.tab) || undefined + } + }; + } + }, { path: prefixPath( "/opportunities/sprint-with-us/:opportunityId/proposals/:proposalId/export" @@ -724,6 +786,46 @@ const router: router_.Router = { route.value.tab ? `?tab=${route.value.tab}` : "" }` ); + case "questionEvaluationIndividualSWUCreate": + return prefixPath( + `/opportunities/sprint-with-us/${ + route.value.opportunityId + }/proposals/${ + route.value.proposalId + }/question-evaluations/individual/create${ + route.value.tab ? `?tab=${route.value.tab}` : "" + }` + ); + case "questionEvaluationIndividualSWUEdit": + return prefixPath( + `/opportunities/sprint-with-us/${ + route.value.opportunityId + }/proposals/${ + route.value.proposalId + }/question-evaluations/individual/${route.value.evaluationId}/edit${ + route.value.tab ? `?tab=${route.value.tab}` : "" + }` + ); + case "questionEvaluationConsensusSWUCreate": + return prefixPath( + `/opportunities/sprint-with-us/${ + route.value.opportunityId + }/proposals/${ + route.value.proposalId + }/question-evaluations/consensus/create${ + route.value.tab ? `?tab=${route.value.tab}` : "" + }` + ); + case "questionEvaluationConsensusSWUEdit": + return prefixPath( + `/opportunities/sprint-with-us/${ + route.value.opportunityId + }/proposals/${ + route.value.proposalId + }/question-evaluations/consensus/${route.value.evaluationId}/edit${ + route.value.tab ? `?tab=${route.value.tab}` : "" + }` + ); case "proposalSWUExportOne": return prefixPath( `/opportunities/sprint-with-us/${route.value.opportunityId}/proposals/${route.value.proposalId}/export` diff --git a/src/front-end/typescript/lib/app/types.ts b/src/front-end/typescript/lib/app/types.ts index 706daa7aa..14a063641 100644 --- a/src/front-end/typescript/lib/app/types.ts +++ b/src/front-end/typescript/lib/app/types.ts @@ -45,6 +45,10 @@ import * as PageProposalSWUCreate from "front-end/lib/pages/proposal/sprint-with import * as PageProposalSWUEdit from "front-end/lib/pages/proposal/sprint-with-us/edit"; import * as PageProposalSWUExportAll from "front-end/lib/pages/proposal/sprint-with-us/export/all"; import * as PageProposalSWUExportOne from "front-end/lib/pages/proposal/sprint-with-us/export/one"; +import * as PageEvaluationIndividualSWUCreate from "front-end/lib/pages/question-evaluation/sprint-with-us/create-individual"; +import * as PageEvaluationIndividualSWUEdit from "front-end/lib/pages/question-evaluation/sprint-with-us/edit-individual"; +import * as PageEvaluationConsensusSWUCreate from "front-end/lib/pages/question-evaluation/sprint-with-us/create-consensus"; +import * as PageEvaluationConsensusSWUEdit from "front-end/lib/pages/question-evaluation/sprint-with-us/edit-individual"; import * as PageProposalSWUView from "front-end/lib/pages/proposal/sprint-with-us/view"; import * as PageSignIn from "front-end/lib/pages/sign-in"; import * as PageSignOut from "front-end/lib/pages/sign-out"; @@ -90,6 +94,22 @@ export type Route = | ADT<"proposalSWUCreate", PageProposalSWUCreate.RouteParams> | ADT<"proposalSWUEdit", PageProposalSWUEdit.RouteParams> | ADT<"proposalSWUView", PageProposalSWUView.RouteParams> + | ADT< + "questionEvaluationIndividualSWUCreate", + PageEvaluationIndividualSWUCreate.RouteParams + > + | ADT< + "questionEvaluationIndividualSWUEdit", + PageEvaluationIndividualSWUEdit.RouteParams + > + | ADT< + "questionEvaluationConsensusSWUCreate", + PageEvaluationConsensusSWUCreate.RouteParams + > + | ADT< + "questionEvaluationConsensusSWUEdit", + PageEvaluationConsensusSWUEdit.RouteParams + > | ADT<"proposalSWUExportOne", PageProposalSWUExportOne.RouteParams> | ADT<"proposalSWUExportAll", PageProposalSWUExportAll.RouteParams> | ADT<"opportunitySWUCreate", PageOpportunitySWUCreate.RouteParams> diff --git a/src/front-end/typescript/lib/app/update.ts b/src/front-end/typescript/lib/app/update.ts index 837685b5f..7e3fa28d8 100644 --- a/src/front-end/typescript/lib/app/update.ts +++ b/src/front-end/typescript/lib/app/update.ts @@ -54,6 +54,10 @@ import * as PageProposalSWUEdit from "front-end/lib/pages/proposal/sprint-with-u import * as PageProposalSWUExportAll from "front-end/lib/pages/proposal/sprint-with-us/export/all"; import * as PageProposalSWUExportOne from "front-end/lib/pages/proposal/sprint-with-us/export/one"; import * as PageProposalSWUView from "front-end/lib/pages/proposal/sprint-with-us/view"; +import * as PageQuestionEvaluationIndividualSWUCreate from "front-end/lib/pages/question-evaluation/sprint-with-us/create-individual"; +import * as PageQuestionEvaluationIndividualSWUEdit from "front-end/lib/pages/question-evaluation/sprint-with-us/edit-individual"; +import * as PageQuestionEvaluationConsensusSWUCreate from "front-end/lib/pages/question-evaluation/sprint-with-us/create-consensus"; +import * as PageQuestionEvaluationConsensusSWUEdit from "front-end/lib/pages/question-evaluation/sprint-with-us/edit-individual"; import * as PageProposalTWUCreate from "front-end/lib/pages/proposal/team-with-us/create"; import * as PageProposalTWUView from "front-end/lib/pages/proposal/team-with-us/view"; import * as PageProposalTWUEdit from "front-end/lib/pages/proposal/team-with-us/edit"; @@ -181,6 +185,50 @@ function initPage( return adt("pageProposalSWUView", value) as Msg; } }); + case "questionEvaluationIndividualSWUCreate": + return component.app.initPage({ + ...defaultPageInitParams, + pageStatePath: ["pages", "proposalSWUView"], + pageRouteParams: route.value, + pageInit: PageQuestionEvaluationIndividualSWUCreate.component.init, + pageGetMetadata: PageProposalSWUView.component.getMetadata, + mapPageMsg(value) { + return adt("pageProposalSWUView", value) as Msg; + } + }); + case "questionEvaluationIndividualSWUEdit": + return component.app.initPage({ + ...defaultPageInitParams, + pageStatePath: ["pages", "proposalSWUView"], + pageRouteParams: route.value, + pageInit: PageQuestionEvaluationIndividualSWUEdit.component.init, + pageGetMetadata: PageProposalSWUView.component.getMetadata, + mapPageMsg(value) { + return adt("pageProposalSWUView", value) as Msg; + } + }); + case "questionEvaluationConsensusSWUCreate": + return component.app.initPage({ + ...defaultPageInitParams, + pageStatePath: ["pages", "proposalSWUView"], + pageRouteParams: route.value, + pageInit: PageQuestionEvaluationConsensusSWUCreate.component.init, + pageGetMetadata: PageProposalSWUView.component.getMetadata, + mapPageMsg(value) { + return adt("pageProposalSWUView", value) as Msg; + } + }); + case "questionEvaluationConsensusSWUEdit": + return component.app.initPage({ + ...defaultPageInitParams, + pageStatePath: ["pages", "proposalSWUView"], + pageRouteParams: route.value, + pageInit: PageQuestionEvaluationConsensusSWUEdit.component.init, + pageGetMetadata: PageProposalSWUView.component.getMetadata, + mapPageMsg(value) { + return adt("pageProposalSWUView", value) as Msg; + } + }); case "opportunitySWUEdit": return component.app.initPage({ diff --git a/src/front-end/typescript/lib/app/view/index.tsx b/src/front-end/typescript/lib/app/view/index.tsx index 13f99f834..ac1dab03b 100644 --- a/src/front-end/typescript/lib/app/view/index.tsx +++ b/src/front-end/typescript/lib/app/view/index.tsx @@ -261,6 +261,10 @@ function pageToViewPageProps( (value) => ({ tag: "pageProposalSWUEdit", value }) ); + case "questionEvaluationIndividualSWUCreate": + case "questionEvaluationIndividualSWUEdit": + case "questionEvaluationConsensusSWUCreate": + case "questionEvaluationConsensusSWUEdit": case "proposalSWUView": return makeViewPageProps( props, From 7ea31c0c4a1ee79fd79a14ea5bf79376d85a4de0 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 9 Oct 2024 16:43:17 -0700 Subject: [PATCH 46/58] refactor: migration changes for refactor --- .../20240718222006_swu-evaluation-tables.ts | 75 +++++++++++++++++-- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/src/migrations/tasks/20240718222006_swu-evaluation-tables.ts b/src/migrations/tasks/20240718222006_swu-evaluation-tables.ts index 4d56c1227..87a11551d 100644 --- a/src/migrations/tasks/20240718222006_swu-evaluation-tables.ts +++ b/src/migrations/tasks/20240718222006_swu-evaluation-tables.ts @@ -8,8 +8,7 @@ enum SWUOpportunityStatus { Draft = "DRAFT", UnderReview = "UNDER_REVIEW", Published = "PUBLISHED", - TeamQuestionsPanelEvaluation = "QUESTIONS_PANEL_EVAL", - TeamQuestionsPanelConsensus = "QUESTIONS_PANEL_CONSENSUS", + EvaluationTeamQuestionsPanel = "EVAL_QUESTIONS_PANEL", EvaluationTeamQuestions = "EVAL_QUESTIONS", EvaluationCodeChallenge = "EVAL_CC", EvaluationTeamScenario = "EVAL_SCENARIO", @@ -30,8 +29,40 @@ enum PreviousSWUOpportunityStatus { Canceled = "CANCELED" } +enum SWUProposalStatus { + Draft = "DRAFT", + Submitted = "SUBMITTED", + TeamQuestionsPanelIndividual = "QUESTIONS_PANEL_INDIVIDUAL", + TeamQuestionsPanelConsensus = "QUESTIONS_PANEL_CONESENSUS", + UnderReviewTeamQuestions = "UNDER_REVIEW_QUESTIONS", + EvaluatedTeamQuestions = "EVALUATED_QUESTIONS", + UnderReviewCodeChallenge = "UNDER_REVIEW_CODE_CHALLENGE", + EvaluatedCodeChallenge = "EVALUATED_CODE_CHALLENGE", + UnderReviewTeamScenario = "UNDER_REVIEW_TEAM_SCENARIO", + EvaluatedTeamScenario = "EVALUATED_TEAM_SCENARIO", + Awarded = "AWARDED", + NotAwarded = "NOT_AWARDED", + Disqualified = "DISQUALIFIED", + Withdrawn = "WITHDRAWN" +} + +enum PreviousSWUProposalStatus { + Draft = "DRAFT", + Submitted = "SUBMITTED", + UnderReviewTeamQuestions = "UNDER_REVIEW_QUESTIONS", + EvaluatedTeamQuestions = "EVALUATED_QUESTIONS", + UnderReviewCodeChallenge = "UNDER_REVIEW_CODE_CHALLENGE", + EvaluatedCodeChallenge = "EVALUATED_CODE_CHALLENGE", + UnderReviewTeamScenario = "UNDER_REVIEW_TEAM_SCENARIO", + EvaluatedTeamScenario = "EVALUATED_TEAM_SCENARIO", + Awarded = "AWARDED", + NotAwarded = "NOT_AWARDED", + Disqualified = "DISQUALIFIED", + Withdrawn = "WITHDRAWN" +} + enum SWUTeamQuestionResponseEvaluationType { - Conensus = "CONSENSUS", + Consensus = "CONSENSUS", Individual = "INDIVIDUAL" } @@ -41,6 +72,20 @@ export enum SWUTeamQuestionResponseEvaluationStatus { } export async function up(connection: Knex): Promise { + await connection.schema.raw( + ' \ + ALTER TABLE "swuProposalStatuses" \ + DROP CONSTRAINT "swuProposalStatuses_status_check" \ + ' + ); + + await connection.schema.raw(` \ + ALTER TABLE "swuProposalStatuses" \ + ADD CONSTRAINT "swuProposalStatuses_status_check" \ + CHECK (status IN ('${Object.values(SWUProposalStatus).join("','")}')) \ + `); + logger.info("Modified constraint on swuProposalStatuses"); + await connection.schema.raw( ' \ ALTER TABLE "swuOpportunityStatuses" \ @@ -118,6 +163,27 @@ export async function up(connection: Knex): Promise { } export async function down(connection: Knex): Promise { + await connection.schema.raw( + ' \ + ALTER TABLE "swuProposalStatuses" \ + DROP CONSTRAINT "swuProposalStatuses_status_check" \ + ' + ); + + await connection("swuProposalStatuses") + .where({ status: "QUESTIONS_PANEL_INDIVIDUAL" }) + .where({ status: "QUESTIONS_PANEL_CONSENSUS" }) + .del(); + + await connection.schema.raw(` \ + ALTER TABLE "swuProposalStatuses" \ + ADD CONSTRAINT "swuProposalStatuses_status_check" \ + CHECK (status IN ('${Object.values(PreviousSWUProposalStatus).join( + "','" + )}')) \ + `); + logger.info("Reverted constraint on swuProposalStatuses"); + await connection.schema.raw( ' \ ALTER TABLE "swuOpportunityStatuses" \ @@ -126,8 +192,7 @@ export async function down(connection: Knex): Promise { ); await connection("swuOpportunityStatuses") - .where({ status: "QUESTIONS_PANEL_EVAL" }) - .orWhere({ status: "QUESTIONS_PANEL_CONSENSUS" }) + .where({ status: "EVAL_QUESTIONS_PANEL" }) .del(); await connection.schema.raw(` \ From c49ab06f0e0d24407a315f0681d37aafcb417543 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 9 Oct 2024 16:43:45 -0700 Subject: [PATCH 47/58] feat: team questions scaffolding --- .../proposal/sprint-with-us/view/index.tsx | 31 +- .../proposal/sprint-with-us/view/tab/index.ts | 7 + .../view/tab/team-questions.tsx | 1022 +++++++++++------ .../resources/opportunity/sprint-with-us.ts | 55 + 4 files changed, 766 insertions(+), 349 deletions(-) diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx index f8cbc837f..ba6bf0073 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx @@ -26,9 +26,11 @@ import { UserType, User } from "shared/lib/resources/user"; import { adt, ADT, Id } from "shared/lib/types"; import { invalid, valid, Validation } from "shared/lib/validation"; import { SWUOpportunity } from "shared/lib/resources/opportunity/sprint-with-us"; +import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; interface ValidState extends Tab.ParentState { proposal: SWUProposal | null; + questionEvaluations: SWUTeamQuestionResponseEvaluation[]; } export type State_ = Validation< @@ -40,7 +42,16 @@ export type State = State_; export type InnerMsg_ = Tab.ParentInnerMsg< K, - ADT<"onInitResponse", [User, RouteParams, SWUProposal, SWUOpportunity]> + ADT< + "onInitResponse", + [ + User, + RouteParams, + SWUProposal, + SWUOpportunity, + SWUTeamQuestionResponseEvaluation[] + ] + > >; export type InnerMsg = InnerMsg_; @@ -71,7 +82,8 @@ function makeInit(): component_.page.Init< immutable({ proposal: null, tab: null, - sidebar: null + sidebar: null, + questionEvaluations: [] }) ) as State_, [ @@ -91,7 +103,8 @@ function makeInit(): component_.page.Init< shared.sessionUser, routeParams, proposal, - opportunity + opportunity, + [] ]) as Msg; } ) @@ -139,8 +152,13 @@ function makeComponent(): component_.page.Component< extraUpdate: ({ state, msg }) => { switch (msg.tag) { case "onInitResponse": { - const [viewerUser, routeParams, proposal, opportunity] = - msg.value; + const [ + viewerUser, + routeParams, + proposal, + opportunity, + questionEvaluations + ] = msg.value; // Set up the visible tab state. const tabId = routeParams.tab || "proposal"; // Initialize the sidebar. @@ -153,7 +171,8 @@ function makeComponent(): component_.page.Component< const [tabState, tabCmds] = tabComponent.init({ viewerUser, proposal, - opportunity + opportunity, + questionEvaluations }); // Everything checks out, return valid state. return [ diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts index 7f8dfb806..b741c0700 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts @@ -9,6 +9,7 @@ import * as TeamScenarioTab from "front-end/lib/pages/proposal/sprint-with-us/vi import { routeDest } from "front-end/lib/views/link"; import { SWUOpportunity } from "shared/lib/resources/opportunity/sprint-with-us"; import { SWUProposal } from "shared/lib/resources/proposal/sprint-with-us"; +import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; import { User } from "shared/lib/resources/user"; import { adt } from "shared/lib/types"; @@ -33,6 +34,7 @@ export interface Params { proposal: SWUProposal; opportunity: SWUOpportunity; viewerUser: User; + questionEvaluations: SWUTeamQuestionResponseEvaluation[]; } export type InitResponse = null; @@ -191,3 +193,8 @@ export function makeSidebarState( ] }); } + +export function shouldLoadEvaluationsForTab(tabId: TabId): boolean { + const evaluationTabs: TabId[] = ["teamQuestions"]; + return evaluationTabs.includes(tabId); +} diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx index 5d716b8d5..86b972a8d 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx @@ -1,58 +1,71 @@ import { EMPTY_STRING } from "front-end/config"; -import { makeStartLoading, makeStopLoading } from "front-end/lib"; +// import { makeStartLoading, makeStopLoading } from "front-end/lib"; import { Route } from "front-end/lib/app/types"; -import * as FormField from "front-end/lib/components/form-field"; +// import * as FormField from "front-end/lib/components/form-field"; import * as NumberField from "front-end/lib/components/form-field/number"; +import * as LongText from "front-end/lib/components/form-field/long-text"; import { Immutable, immutable, component as component_ } from "front-end/lib/framework"; import * as api from "front-end/lib/http/api"; -import * as toasts from "front-end/lib/pages/proposal/sprint-with-us/lib/toasts"; import ViewTabHeader from "front-end/lib/pages/proposal/sprint-with-us/lib/views/view-tab-header"; import * as Tab from "front-end/lib/pages/proposal/sprint-with-us/view/tab"; import Accordion from "front-end/lib/views/accordion"; -import { iconLinkSymbol, leftPlacement } from "front-end/lib/views/link"; +// import { iconLinkSymbol, leftPlacement } from "front-end/lib/views/link"; import { ProposalMarkdown } from "front-end/lib/views/markdown"; import ReportCardList from "front-end/lib/views/report-card-list"; import Separator from "front-end/lib/views/separator"; import React from "react"; import { Alert, Col, Row } from "reactstrap"; -import { countWords } from "shared/lib"; +import { compareNumbers, countWords } from "shared/lib"; import { - canSWUOpportunityBeScreenedInToCodeChallenge, + // canSWUOpportunityBeScreenedInToCodeChallenge, getQuestionByOrder, - hasSWUOpportunityPassedCodeChallenge, + // hasSWUOpportunityPassedCodeChallenge, hasSWUOpportunityPassedTeamQuestions, - SWUOpportunity + hasSWUOpportunityPassedTeamQuestionsEvaluation, + SWUEvaluationPanelMember, + SWUOpportunity, + SWUOpportunityStatus, + SWUTeamQuestion } from "shared/lib/resources/opportunity/sprint-with-us"; import { NUM_SCORE_DECIMALS, SWUProposal, - SWUProposalStatus, + // SWUProposalStatus, SWUProposalTeamQuestionResponse, - UpdateTeamQuestionScoreBody, UpdateValidationErrors } from "shared/lib/resources/proposal/sprint-with-us"; +import { + getEvaluationById, + getEvaluationScoreByOrder, + SWUTeamQuestionResponseEvaluation, + SWUTeamQuestionResponseEvaluationScores +} from "shared/lib/resources/question-evaluation/sprint-with-us"; import { adt, ADT } from "shared/lib/types"; import { invalid } from "shared/lib/validation"; -import { validateTeamQuestionScoreScore } from "shared/lib/validation/proposal/sprint-with-us"; +import { + validateSWUTeamQuestionResponseEvaluationScoreNotes, + validateSWUTeamQuestionResponseEvaluationScoreScore +} from "shared/lib/validation/question-evaluation/sprint-with-us"; -type ModalId = "enterScore"; +interface EvaluationScore { + score: Immutable; + notes: Immutable; +} export interface State extends Tab.Params { - showModal: ModalId | null; enterScoreLoading: number; screenToFromLoading: number; openAccordions: Set; - scores: Array>; + individualEvaluationScores: EvaluationScore[][]; + consensusEvaluationScores: EvaluationScore[]; } export type InnerMsg = | ADT<"toggleAccordion", number> - | ADT<"showModal", ModalId> - | ADT<"hideModal"> | ADT<"submitScore"> | ADT< "onSubmitScoreResponse", @@ -62,50 +75,206 @@ export type InnerMsg = | ADT<"onScreenInResponse", SWUProposal | null> | ADT<"screenOut"> | ADT<"onScreenOutResponse", SWUProposal | null> - | ADT<"scoreMsg", [number, NumberField.Msg]>; //[index, msg] + | ADT< + "scoreMsgIndividual", + { childMsg: NumberField.Msg; rIndex: number; eIndex: number } + > + | ADT< + "notesMsgIndividual", + { childMsg: LongText.Msg; rIndex: number; eIndex: number } + > + | ADT<"scoreMsgConsensus", { childMsg: NumberField.Msg; rIndex: number }> + | ADT<"notesMsgConsensus", { childMsg: LongText.Msg; rIndex: number }>; export type Msg = component_.page.Msg; -function initScores( +const initScore = ( + score: SWUTeamQuestionResponseEvaluationScores, + question: SWUTeamQuestion | null, + order: number, + rIndex: number +): [ + EvaluationScore, + [ReturnType[1], ReturnType[1]] +] => { + const [scoreState, scoreCmds] = NumberField.init({ + errors: [], + validate: (v) => { + if (v === null) { + return invalid(["Please enter a valid score."]); + } + return validateSWUTeamQuestionResponseEvaluationScoreScore( + v, + question?.score || 0 + ); + }, + child: { + step: 0.01, + value: + score.score === null || score.score === undefined ? null : score.score, + id: `swu-proposal-question-score-${order}-${rIndex}` + } + }); + const [notesState, notesCmds] = LongText.init({ + errors: [], + validate: validateSWUTeamQuestionResponseEvaluationScoreNotes, + child: { + value: score?.notes ?? "", + id: `swu-proposal-question-notes-${order}-${rIndex}` + } + }); + + return [ + { + score: immutable(scoreState), + notes: immutable(notesState) + }, + [scoreCmds, notesCmds] + ]; +}; + +function initIndividualScores( opp: SWUOpportunity, - prop: SWUProposal -): [Immutable[], component_.Cmd[]] { + prop: SWUProposal, + evaluators: SWUEvaluationPanelMember[], + evaluations: SWUTeamQuestionResponseEvaluation[] +): [EvaluationScore[][], component_.Cmd[]] { return (prop.teamQuestionResponses || []).reduce( - ([states, cmds], r, i) => { + ([states, cmds], r, rIndex) => { + // Gets the opp by response order const question = getQuestionByOrder(opp, r.order); - const [state, cmd] = NumberField.init({ - errors: [], - validate: (v) => { - if (v === null) { - return invalid(["Please enter a valid score."]); + + const [questionScoreStates, questionScoreCmds] = evaluators.reduce< + component_.base.InitReturnValue + >( + ([scoreStates, scoreCmds], epm, eIndex) => { + const evaluation = getEvaluationById(evaluations, epm.user.id); + if (!evaluation) { + return [scoreStates, scoreCmds]; + } + const score = getEvaluationScoreByOrder(evaluation, r.order); + if (!score) { + return [scoreStates, scoreCmds]; } - return validateTeamQuestionScoreScore(v, question?.score || 0); + + const [scoreState, [scoreScoreCmds, scoreNotesCmds]] = initScore( + score, + question, + epm.order, + rIndex + ); + + return [ + [...scoreStates, scoreState], + [ + ...scoreCmds, + ...[ + ...component_.cmd.mapMany( + scoreScoreCmds, + (childMsg) => + adt("scoreMsgIndividual", { + rIndex, + eIndex, + childMsg + }) as Msg + ), + ...component_.cmd.mapMany( + scoreNotesCmds, + (childMsg) => + adt("notesMsgIndividual", { + rIndex, + eIndex, + childMsg + }) as Msg + ) + ] + ] + ]; }, - child: { - step: 0.01, - value: r.score === null || r.score === undefined ? null : r.score, - id: `swu-proposal-question-score-${i}` - } - }); + [[], []] + ); + + return [ + [...states, questionScoreStates], + [...cmds, ...questionScoreCmds] + ]; + }, + [[], []] as [EvaluationScore[][], component_.Cmd[]] + ); +} + +function initConsensusScores( + opp: SWUOpportunity, + prop: SWUProposal, + chair: SWUEvaluationPanelMember, + evaluations: SWUTeamQuestionResponseEvaluation[] +): [EvaluationScore[], component_.Cmd[]] { + return (prop.teamQuestionResponses || []).reduce< + [EvaluationScore[], component_.Cmd[]] + >( + ([states, cmds], r, rIndex) => { + const question = getQuestionByOrder(opp, r.order); + + const evaluation = getEvaluationById(evaluations, chair.user.id); + if (!evaluation) { + return [states, cmds]; + } + const score = getEvaluationScoreByOrder(evaluation, r.order); + if (!score) { + return [states, cmds]; + } + + const [scoreState, [scoreScoreCmds, scoreNotesCmds]] = initScore( + score, + question, + chair.order, + rIndex + ); + return [ - [...states, immutable(state)], + [...states, scoreState], [ ...cmds, ...component_.cmd.mapMany( - cmd, - (msg) => adt("scoreMsg", [i, msg]) as Msg + scoreScoreCmds, + (childMsg) => + adt("scoreMsgConsensus", { + rIndex, + childMsg + }) as Msg + ), + ...component_.cmd.mapMany( + scoreNotesCmds, + (childMsg) => + adt("notesMsgConsensus", { + rIndex, + childMsg + }) as Msg ) ] ]; }, - [[], []] as [Immutable[], component_.Cmd[]] + [[], []] ); } -const init: component_.base.Init = (params) => { - const [scoreStates, scoreCmds] = initScores( +export const init: component_.base.Init = (params) => { + const sortedEvaluators = + params.opportunity.evaluationPanel?.sort((a, b) => + compareNumbers(a.order, b.order) + ) ?? []; + const chair = sortedEvaluators.filter((epm) => epm.chair)[0]; + const [individualScoreStates, individualScoreCmds] = initIndividualScores( params.opportunity, - params.proposal + params.proposal, + sortedEvaluators.filter((epm) => epm.evaluator), + params.questionEvaluations + ); + const [consensusScoreStates, consensusScoreCmds] = initConsensusScores( + params.opportunity, + params.proposal, + chair, + params.questionEvaluations ); return [ { @@ -116,16 +285,17 @@ const init: component_.base.Init = (params) => { openAccordions: new Set( params.proposal.teamQuestionResponses.map((p, i) => i) ), - scores: scoreStates + individualEvaluationScores: individualScoreStates, + consensusEvaluationScores: consensusScoreStates }, - scoreCmds + [...individualScoreCmds, ...consensusScoreCmds] ]; }; -const startScreenToFromLoading = makeStartLoading("screenToFromLoading"); -const stopScreenToFromLoading = makeStopLoading("screenToFromLoading"); -const startEnterScoreLoading = makeStartLoading("enterScoreLoading"); -const stopEnterScoreLoading = makeStopLoading("enterScoreLoading"); +// const startScreenToFromLoading = makeStartLoading("screenToFromLoading"); +// const stopScreenToFromLoading = makeStopLoading("screenToFromLoading"); +// const startEnterScoreLoading = makeStartLoading("enterScoreLoading"); +// const stopEnterScoreLoading = makeStopLoading("enterScoreLoading"); const update: component_.base.Update = ({ state, msg }) => { switch (msg.tag) { @@ -141,191 +311,244 @@ const update: component_.base.Update = ({ state, msg }) => { }), [] ]; - case "showModal": - return [state.set("showModal", msg.value), []]; - case "hideModal": - if (state.enterScoreLoading > 0) { - return [state, []]; - } - return [state.set("showModal", null), []]; - case "submitScore": { - const scores = state.proposal.teamQuestionResponses.reduce( - (acc, r, i) => { - if (!acc) { - return null; - } - const field = state.scores[i]; - if (!field) { - return null; - } - const score = FormField.getValue(field); - if (score === null) { - return null; - } - acc.push({ - order: r.order, - score - }); - return acc; - }, - [] as UpdateTeamQuestionScoreBody[] | null - ); - if (scores === null) { - return [state, []]; - } - return [ - startEnterScoreLoading(state), - [ - api.proposals.swu.update()( - state.proposal.id, - adt("scoreQuestions", scores), - (response) => adt("onSubmitScoreResponse", response) - ) - ] - ]; - } - case "onSubmitScoreResponse": { - state = stopEnterScoreLoading(state); - const result = msg.value; - switch (result.tag) { - case "valid": { - const [scoreStates, scoreCmds] = initScores( - state.opportunity, - result.value - ); - return [ - state - .set("scores", scoreStates) - .set("showModal", null) - .set("proposal", result.value), - [ - ...scoreCmds, - component_.cmd.dispatch( - component_.global.showToastMsg( - adt("success", toasts.scored.success("Team Questions")) - ) - ) - ] - ]; - } - case "invalid": { - let scores = state.scores; - if ( - result.value.proposal && - result.value.proposal.tag === "scoreQuestions" - ) { - scores = result.value.proposal.value.map((e, i) => - FormField.setErrors(scores[i], e.score || []) - ); - } - return [ - state.set("scores", scores), - [ - component_.cmd.dispatch( - component_.global.showToastMsg( - adt("error", toasts.scored.error("Team Questions")) - ) - ) - ] - ]; - } - case "unhandled": - default: - return [ - state, - [ - component_.cmd.dispatch( - component_.global.showToastMsg( - adt("error", toasts.scored.error("Team Questions")) - ) - ) - ] - ]; - } - } - case "screenIn": - return [ - startScreenToFromLoading(state).set("showModal", null), - [ - api.proposals.swu.update()( - state.proposal.id, - adt("screenInToCodeChallenge", ""), - (response) => - adt( - "onScreenInResponse", - api.isValid(response) ? response.value : null - ) - ) - ] - ]; - case "onScreenInResponse": { - state = stopScreenToFromLoading(state); - const proposal = msg.value; - if (proposal) { - const [scoreStates, scoreCmds] = initScores( - state.opportunity, - proposal - ); - return [ - state.set("scores", scoreStates).set("proposal", proposal), - [ - ...scoreCmds, - component_.cmd.dispatch( - component_.global.showToastMsg( - adt("success", toasts.screenedIn.success) - ) - ) - ] - ]; - } else { - return [state, []]; - } - } - case "screenOut": - return [ - startScreenToFromLoading(state).set("showModal", null), - [ - api.proposals.swu.update()( - state.proposal.id, - adt("screenOutFromCodeChallenge", ""), - (response) => - adt( - "onScreenOutResponse", - api.isValid(response) ? response.value : null - ) - ) - ] - ]; - case "onScreenOutResponse": { - state = stopScreenToFromLoading(state); - const proposal = msg.value; - if (proposal) { - const [scoreStates, scoreCmds] = initScores( - state.opportunity, - proposal - ); - return [ - state.set("scores", scoreStates).set("proposal", proposal), - [ - ...scoreCmds, - component_.cmd.dispatch( - component_.global.showToastMsg( - adt("success", toasts.screenedOut.success) - ) - ) - ] - ]; - } else { - return [state, []]; - } - } - case "scoreMsg": + // case "submitScore": { + // const scores = state.proposal.teamQuestionResponses.reduce( + // (acc, r, i) => { + // if (!acc) { + // return null; + // } + // const field = state.scores[i]; + // if (!field) { + // return null; + // } + // const score = FormField.getValue(field); + // if (score === null) { + // return null; + // } + // acc.push({ + // order: r.order, + // score + // }); + // return acc; + // }, + // [] as UpdateTeamQuestionScoreBody[] | null + // ); + // if (scores === null) { + // return [state, []]; + // } + // return [ + // startEnterScoreLoading(state), + // [ + // api.proposals.swu.update()( + // state.proposal.id, + // adt("scoreQuestions", scores), + // (response) => adt("onSubmitScoreResponse", response) + // ) + // ] + // ]; + // } + // case "onSubmitScoreResponse": { + // state = stopEnterScoreLoading(state); + // const result = msg.value; + // switch (result.tag) { + // case "valid": { + // const [scoreStates, scoreCmds] = initScores( + // state.opportunity, + // result.value + // ); + // return [ + // state + // .set("scores", scoreStates) + // .set("showModal", null) + // .set("proposal", result.value), + // [ + // ...scoreCmds, + // component_.cmd.dispatch( + // component_.global.showToastMsg( + // adt("success", toasts.scored.success("Team Questions")) + // ) + // ) + // ] + // ]; + // } + // case "invalid": { + // let scores = state.scores; + // if ( + // result.value.proposal && + // result.value.proposal.tag === "scoreQuestions" + // ) { + // scores = result.value.proposal.value.map((e, i) => + // FormField.setErrors(scores[i], e.score || []) + // ); + // } + // return [ + // state.set("scores", scores), + // [ + // component_.cmd.dispatch( + // component_.global.showToastMsg( + // adt("error", toasts.scored.error("Team Questions")) + // ) + // ) + // ] + // ]; + // } + // case "unhandled": + // default: + // return [ + // state, + // [ + // component_.cmd.dispatch( + // component_.global.showToastMsg( + // adt("error", toasts.scored.error("Team Questions")) + // ) + // ) + // ] + // ]; + // } + // } + // case "screenIn": + // return [ + // startScreenToFromLoading(state).set("showModal", null), + // [ + // api.proposals.swu.update()( + // state.proposal.id, + // adt("screenInToCodeChallenge", ""), + // (response) => + // adt( + // "onScreenInResponse", + // api.isValid(response) ? response.value : null + // ) + // ) + // ] + // ]; + // case "onScreenInResponse": { + // state = stopScreenToFromLoading(state); + // const proposal = msg.value; + // if (proposal) { + // const [scoreStates, scoreCmds] = initScores( + // state.opportunity, + // proposal + // ); + // return [ + // state.set("scores", scoreStates).set("proposal", proposal), + // [ + // ...scoreCmds, + // component_.cmd.dispatch( + // component_.global.showToastMsg( + // adt("success", toasts.screenedIn.success) + // ) + // ) + // ] + // ]; + // } else { + // return [state, []]; + // } + // } + // case "screenOut": + // return [ + // startScreenToFromLoading(state).set("showModal", null), + // [ + // api.proposals.swu.update()( + // state.proposal.id, + // adt("screenOutFromCodeChallenge", ""), + // (response) => + // adt( + // "onScreenOutResponse", + // api.isValid(response) ? response.value : null + // ) + // ) + // ] + // ]; + // case "onScreenOutResponse": { + // state = stopScreenToFromLoading(state); + // const proposal = msg.value; + // if (proposal) { + // const [scoreStates, scoreCmds] = initScores( + // state.opportunity, + // proposal + // ); + // return [ + // state.set("scores", scoreStates).set("proposal", proposal), + // [ + // ...scoreCmds, + // component_.cmd.dispatch( + // component_.global.showToastMsg( + // adt("success", toasts.screenedOut.success) + // ) + // ) + // ] + // ]; + // } else { + // return [state, []]; + // } + // } + case "scoreMsgIndividual": return component_.base.updateChild({ state, - childStatePath: ["scores", String(msg.value[0])], + childStatePath: [ + "individualEvaluationScores", + String(msg.value.rIndex), + String(msg.value.eIndex), + "score" + ], childUpdate: NumberField.update, - childMsg: msg.value[1], - mapChildMsg: (value) => adt("scoreMsg", [msg.value[0], value]) as Msg + childMsg: msg.value.childMsg, + mapChildMsg: (value) => + adt("scoreMsgIndividual", { + rIndex: msg.value.rIndex, + eIndex: msg.value.eIndex, + childMsg: value + }) as Msg + }); + case "notesMsgIndividual": + return component_.base.updateChild({ + state, + childStatePath: [ + "individualEvaluationScores", + String(msg.value.rIndex), + String(msg.value.eIndex), + "notes" + ], + childUpdate: LongText.update, + childMsg: msg.value.childMsg, + mapChildMsg: (value) => + adt("notesMsgIndividual", { + rIndex: msg.value.rIndex, + eIndex: msg.value.eIndex, + childMsg: value + }) as Msg + }); + case "scoreMsgConsensus": + return component_.base.updateChild({ + state, + childStatePath: [ + "consensusEvaluationScores", + String(msg.value.rIndex), + "score" + ], + childUpdate: NumberField.update, + childMsg: msg.value.childMsg, + mapChildMsg: (value) => + adt("scoreMsgConsensus", { + rIndex: msg.value.rIndex, + childMsg: value + }) as Msg + }); + case "notesMsgConsensus": + return component_.base.updateChild({ + state, + childStatePath: [ + "consensusEvaluationScores", + String(msg.value.rIndex), + "notes" + ], + childUpdate: LongText.update, + childMsg: msg.value.childMsg, + mapChildMsg: (value) => + adt("notesMsgConsensus", { + rIndex: msg.value.rIndex, + childMsg: value + }) as Msg }); default: return [state, []]; @@ -385,10 +608,10 @@ const TeamQuestionResponseView: component_.base.View< ); }; -const view: component_.base.ComponentView = ({ - state, - dispatch -}) => { +const TeamQuestionResponsesView: component_.base.View<{ + state: State; + dispatch: component_.base.Dispatch; +}> = ({ state, dispatch }) => { const show = hasSWUOpportunityPassedTeamQuestions(state.opportunity); return (
@@ -440,12 +663,125 @@ const view: component_.base.ComponentView = ({ ); }; -function isValid(state: Immutable): boolean { - return state.scores.reduce( - (acc, s) => acc && FormField.isValid(s), - true as boolean +const TeamQuestionResponseEvalView: component_.base.View< + TeamQuestionResponseViewProps +> = ({ opportunity, response, index, isOpen, className, toggleAccordion }) => { + const question = getQuestionByOrder(opportunity, response.order); + if (!question) { + return null; + } + return ( + toggleAccordion()} + color="info" + title={`Question ${index + 1}`} + titleClassName="h3 mb-0" + chevronWidth={1.5} + chevronHeight={1.5} + open={isOpen}> +

{question.question}

+
+
+ {countWords(response.response)} / {question.wordLimit} word + {question.wordLimit === 1 ? "" : "s"} +
+ + | + +
+ {response.score === undefined || response.score === null + ? `Unscored (${question.score} point${ + question.score === 1 ? "" : "s" + } available)` + : `${response.score} / ${question.score} point${ + question.score === 1 ? "" : "s" + }`} +
+
+ +
{question.guideline}
+
+ +
); -} +}; + +const TeamQuestionResponsesEvalView: component_.base.View<{ + state: State; + dispatch: component_.base.Dispatch; +}> = ({ state, dispatch }) => { + const show = hasSWUOpportunityPassedTeamQuestionsEvaluation( + state.opportunity + ); + return ( +
+ + {state.proposal.questionsScore !== null && + state.proposal.questionsScore !== undefined ? ( + + + + + + ) : null} +
+ + + {show ? ( +
+

Team Questions{"'"} Responses

+ {state.proposal.teamQuestionResponses.map((r, i, rs) => ( + dispatch(adt("toggleAccordion", i))} + index={i} + response={r} + /> + ))} +
+ ) : ( + "This proposal's team questions will be available once the opportunity closes." + )} + +
+
+
+ ); +}; + +const view: component_.base.ComponentView = ({ + state, + dispatch +}) => { + return state.opportunity.status === + SWUOpportunityStatus.EvaluationTeamQuestionsPanel ? ( + + ) : ( + + ); +}; + +// function isValid(state: Immutable): boolean { +// return state.scores.reduce( +// (acc, s) => acc && FormField.isValid(s), +// true as boolean +// ); +// } export const component: Tab.Component = { init, @@ -456,116 +792,116 @@ export const component: Tab.Component = { return component_.page.readyMsg(); }, - getModal: (state) => { - const isEnterScoreLoading = state.enterScoreLoading > 0; - const valid = isValid(state); - switch (state.showModal) { - case "enterScore": - return component_.page.modal.show({ - title: "Enter Score", - onCloseMsg: adt("hideModal") as Msg, - actions: [ - { - text: "Submit Score", - icon: "star-full", - color: "primary", - button: true, - loading: isEnterScoreLoading, - disabled: isEnterScoreLoading || !valid, - msg: adt("submitScore") - }, - { - text: "Cancel", - color: "secondary", - disabled: isEnterScoreLoading, - msg: adt("hideModal") - } - ], - body: (dispatch) => ( -
-

- Provide a score for each team question response submitted by the - proponent. -

- {state.scores.map((s, i) => { - return ( - adt("scoreMsg" as const, [i, v]) as Msg - )} - state={s} - /> - ); - })} -
- ) - }); - case null: - return component_.page.modal.hide(); - } - }, + // getModal: (state) => { + // const isEnterScoreLoading = state.enterScoreLoading > 0; + // const valid = isValid(state); + // switch (state.showModal) { + // case "enterScore": + // return component_.page.modal.show({ + // title: "Enter Score", + // onCloseMsg: adt("hideModal") as Msg, + // actions: [ + // { + // text: "Submit Score", + // icon: "star-full", + // color: "primary", + // button: true, + // loading: isEnterScoreLoading, + // disabled: isEnterScoreLoading || !valid, + // msg: adt("submitScore") + // }, + // { + // text: "Cancel", + // color: "secondary", + // disabled: isEnterScoreLoading, + // msg: adt("hideModal") + // } + // ], + // body: (dispatch) => ( + //
+ //

+ // Provide a score for each team question response submitted by the + // proponent. + //

+ // {state.scores.map((s, i) => { + // return ( + // adt("scoreMsg" as const, [i, v]) as Msg + // )} + // state={s} + // /> + // ); + // })} + //
+ // ) + // }); + // case null: + // return component_.page.modal.hide(); + // } + // }, - getActions: ({ state, dispatch }) => { + getActions: ({ state }) => { const proposal = state.proposal; const propStatus = proposal.status; - const isScreenToFromLoading = state.screenToFromLoading > 0; + // const isScreenToFromLoading = state.screenToFromLoading > 0; switch (propStatus) { - case SWUProposalStatus.UnderReviewTeamQuestions: - return component_.page.actions.links([ - { - children: "Enter Score", - symbol_: leftPlacement(iconLinkSymbol("star-full")), - button: true, - color: "primary", - onClick: () => dispatch(adt("showModal", "enterScore" as const)) - } - ]); - case SWUProposalStatus.EvaluatedTeamQuestions: - return component_.page.actions.links([ - ...(canSWUOpportunityBeScreenedInToCodeChallenge(state.opportunity) - ? [ - { - children: "Screen In", - symbol_: leftPlacement(iconLinkSymbol("stars")), - loading: isScreenToFromLoading, - disabled: isScreenToFromLoading, - button: true, - color: "primary" as const, - onClick: () => dispatch(adt("screenIn" as const)) - } - ] - : []), - { - children: "Edit Score", - symbol_: leftPlacement(iconLinkSymbol("star-full")), - disabled: isScreenToFromLoading, - button: true, - color: "info", - onClick: () => dispatch(adt("showModal", "enterScore" as const)) - } - ]) as component_.page.Actions; - case SWUProposalStatus.UnderReviewCodeChallenge: - if (hasSWUOpportunityPassedCodeChallenge(state.opportunity)) { - return component_.page.actions.none(); - } - return component_.page.actions.links([ - { - children: "Screen Out", - symbol_: leftPlacement(iconLinkSymbol("ban")), - loading: isScreenToFromLoading, - disabled: isScreenToFromLoading, - button: true, - color: "danger", - onClick: () => dispatch(adt("screenOut" as const)) - } - ]); + // case SWUProposalStatus.UnderReviewTeamQuestions: + // return component_.page.actions.links([ + // { + // children: "Enter Score", + // symbol_: leftPlacement(iconLinkSymbol("star-full")), + // button: true, + // color: "primary", + // onClick: () => dispatch(adt("showModal", "enterScore" as const)) + // } + // ]); + // case SWUProposalStatus.EvaluatedTeamQuestions: + // return component_.page.actions.links([ + // ...(canSWUOpportunityBeScreenedInToCodeChallenge(state.opportunity) + // ? [ + // { + // children: "Screen In", + // symbol_: leftPlacement(iconLinkSymbol("stars")), + // loading: isScreenToFromLoading, + // disabled: isScreenToFromLoading, + // button: true, + // color: "primary" as const, + // onClick: () => dispatch(adt("screenIn" as const)) + // } + // ] + // : []), + // { + // children: "Edit Score", + // symbol_: leftPlacement(iconLinkSymbol("star-full")), + // disabled: isScreenToFromLoading, + // button: true, + // color: "info", + // onClick: () => dispatch(adt("showModal", "enterScore" as const)) + // } + // ]) as component_.page.Actions; + // case SWUProposalStatus.UnderReviewCodeChallenge: + // if (hasSWUOpportunityPassedCodeChallenge(state.opportunity)) { + // return component_.page.actions.none(); + // } + // return component_.page.actions.links([ + // { + // children: "Screen Out", + // symbol_: leftPlacement(iconLinkSymbol("ban")), + // loading: isScreenToFromLoading, + // disabled: isScreenToFromLoading, + // button: true, + // color: "danger", + // onClick: () => dispatch(adt("screenOut" as const)) + // } + // ]); default: return component_.page.actions.none(); } diff --git a/src/shared/lib/resources/opportunity/sprint-with-us.ts b/src/shared/lib/resources/opportunity/sprint-with-us.ts index d63e5745d..4804d5561 100644 --- a/src/shared/lib/resources/opportunity/sprint-with-us.ts +++ b/src/shared/lib/resources/opportunity/sprint-with-us.ts @@ -112,6 +112,7 @@ export function isSWUOpportunityStatusInEvaluation( s: SWUOpportunityStatus ): boolean { switch (s) { + case SWUOpportunityStatus.EvaluationTeamQuestionsPanel: case SWUOpportunityStatus.EvaluationTeamQuestions: case SWUOpportunityStatus.EvaluationCodeChallenge: case SWUOpportunityStatus.EvaluationTeamScenario: @@ -557,6 +558,23 @@ export function canViewSWUOpportunityProposals(o: SWUOpportunity): boolean { ); } +export function canViewSWUOpportunityTeamQuestionResponseEvaluations( + o: SWUOpportunity +): boolean { + // Return true if the opportunity has ever had the `Panel` status. + return ( + !!o.history && + o.history.reduce((acc, record) => { + return ( + acc || + (record.type.tag === "status" && + record.type.value === + SWUOpportunityStatus.EvaluationTeamQuestionsPanel) + ); + }, false as boolean) + ); +} + export function canSWUOpportunityDetailsBeEdited( o: SWUOpportunity, adminsOnly: boolean @@ -612,6 +630,29 @@ export function isSWUOpportunityClosed(o: SWUOpportunity): boolean { ); } +export function hasSWUOpportunityPassedTeamQuestionsEvaluation( + o: Pick +): boolean { + if (!o.history) { + return false; + } + return o.history.reduce((acc, h) => { + if (acc || h.type.tag !== "status") { + return acc; + } + switch (h.type.value) { + case SWUOpportunityStatus.EvaluationTeamQuestionsPanel: + case SWUOpportunityStatus.EvaluationTeamQuestions: + case SWUOpportunityStatus.EvaluationCodeChallenge: + case SWUOpportunityStatus.EvaluationTeamScenario: + case SWUOpportunityStatus.Awarded: + return true; + default: + return false; + } + }, false as boolean); +} + export function hasSWUOpportunityPassedTeamQuestions( o: Pick ): boolean { @@ -701,6 +742,20 @@ export function doesSWUOpportunityStatusAllowGovToViewFullProposal( } } +export function doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations( + s: SWUOpportunityStatus +): boolean { + switch (s) { + case SWUOpportunityStatus.EvaluationTeamQuestions: + case SWUOpportunityStatus.EvaluationCodeChallenge: + case SWUOpportunityStatus.EvaluationTeamScenario: + case SWUOpportunityStatus.Awarded: + return true; + default: + return false; + } +} + export function canViewSWUEvaluationConsensus( s: SWUOpportunityStatus ): boolean { From 513571944f7a953e3b8db5e466a30f9fc23e423a Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 9 Oct 2024 16:44:30 -0700 Subject: [PATCH 48/58] fix: consensus typo --- src/back-end/lib/permissions.ts | 4 ++-- .../lib/resources/question-evaluation/sprint-with-us.ts | 4 ++-- .../lib/resources/question-evaluation/sprint-with-us.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/back-end/lib/permissions.ts b/src/back-end/lib/permissions.ts index 7866a2394..1f9d2ff23 100644 --- a/src/back-end/lib/permissions.ts +++ b/src/back-end/lib/permissions.ts @@ -1004,7 +1004,7 @@ export function editSWUTeamQuestionResponseEvaluation( evaluation.type === SWUTeamQuestionResponseEvaluationType.Individual) || (evaluation.proposal.status === SWUProposalStatus.TeamQuestionsPanelConsensus && - evaluation.type === SWUTeamQuestionResponseEvaluationType.Conensus)) + evaluation.type === SWUTeamQuestionResponseEvaluationType.Consensus)) ); } @@ -1021,7 +1021,7 @@ export function submitSWUTeamQuestionResponseEvaluation( evaluation.type === SWUTeamQuestionResponseEvaluationType.Individual) || (evaluation.proposal.status === SWUProposalStatus.TeamQuestionsPanelConsensus && - evaluation.type === SWUTeamQuestionResponseEvaluationType.Conensus)) + evaluation.type === SWUTeamQuestionResponseEvaluationType.Consensus)) ); } diff --git a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts index 12651f75a..1901b55f8 100644 --- a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts @@ -227,7 +227,7 @@ const create: crud.Create< questionEvaluationValidation.validateSWUTeamQuestionResponseEvaluationType( type, [ - SWUTeamQuestionResponseEvaluationType.Conensus, + SWUTeamQuestionResponseEvaluationType.Consensus, SWUTeamQuestionResponseEvaluationType.Individual ] ); @@ -437,7 +437,7 @@ const update: crud.Update< validatedSWUTeamQuestionResponseEvaluation.value.status !== SWUTeamQuestionResponseEvaluationStatus.Draft && validatedSWUTeamQuestionResponseEvaluation.value.type !== - SWUTeamQuestionResponseEvaluationType.Conensus + SWUTeamQuestionResponseEvaluationType.Consensus ) { return invalid({ permissions: [permissions.ERROR_MESSAGE] diff --git a/src/shared/lib/resources/question-evaluation/sprint-with-us.ts b/src/shared/lib/resources/question-evaluation/sprint-with-us.ts index 99b700149..2ff20f826 100644 --- a/src/shared/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/shared/lib/resources/question-evaluation/sprint-with-us.ts @@ -10,8 +10,8 @@ export function parseSWUTeamQuestionResponseEvaluationType( raw: string ): SWUTeamQuestionResponseEvaluationType | null { switch (raw) { - case SWUTeamQuestionResponseEvaluationType.Conensus: - return SWUTeamQuestionResponseEvaluationType.Conensus; + case SWUTeamQuestionResponseEvaluationType.Consensus: + return SWUTeamQuestionResponseEvaluationType.Consensus; case SWUTeamQuestionResponseEvaluationType.Individual: return SWUTeamQuestionResponseEvaluationType.Individual; default: @@ -33,7 +33,7 @@ export function parseSWUTeamQuestionResponseEvaluationStatus( } export enum SWUTeamQuestionResponseEvaluationType { - Conensus = "CONSENSUS", + Consensus = "CONSENSUS", Individual = "INDIVIDUAL" } From 2afab9b6f745a2fb556e7b526fa92197363a9826 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 16 Oct 2024 16:29:02 -0700 Subject: [PATCH 49/58] refactor: pass evaluation and panel evaluations in separately --- .../sprint-with-us/create-consensus.tsx | 7 ++++--- .../sprint-with-us/create-individual.tsx | 1 + .../sprint-with-us/edit-consensus.tsx | 17 ++++------------- .../sprint-with-us/edit-individual.tsx | 3 ++- 4 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx index 108f4f036..9f9e8cf14 100644 --- a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx @@ -49,8 +49,8 @@ function makeInit(): component_.page.Init< api.evaluations.swu.readMany({ proposalId })((response) => api.isValid(response) ? response.value : null ), - (proposal, opportunity, individualEvaluations) => { - if (!proposal || !opportunity || !individualEvaluations) + (proposal, opportunity, panelEvaluations) => { + if (!proposal || !opportunity || !panelEvaluations) return component_.global.replaceRouteMsg( adt("notFound" as const, { path: routePath }) ); @@ -59,7 +59,8 @@ function makeInit(): component_.page.Init< routeParams, proposal, opportunity, - individualEvaluations + undefined, + panelEvaluations ]) as Msg; } ) diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx index 95ef527ab..470781184 100644 --- a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx @@ -56,6 +56,7 @@ function makeInit(): component_.page.Init< routeParams, proposal, opportunity, + undefined, [] // No evaluations to load ]) as Msg; } diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx index 9a9167ebd..f25c35a74 100644 --- a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx @@ -53,13 +53,8 @@ function makeInit(): component_.page.Init< api.evaluations.swu.readMany({ proposalId })((response) => api.isValid(response) ? response.value : null ), - (proposal, opportunity, consensus, individualEvaluations) => { - if ( - !proposal || - !opportunity || - !consensus || - !individualEvaluations - ) + (proposal, opportunity, evaluation, panelEvaluations) => { + if (!proposal || !opportunity || !evaluation || !panelEvaluations) return component_.global.replaceRouteMsg( adt("notFound" as const, { path: routePath }) ); @@ -68,12 +63,8 @@ function makeInit(): component_.page.Init< routeParams, proposal, opportunity, - [ - consensus, - ...(Array.isArray(individualEvaluations) - ? individualEvaluations - : []) - ] + evaluation, + panelEvaluations ]) as Msg; } ) diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx index b7a3c8bb5..fb772b43d 100644 --- a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx @@ -60,7 +60,8 @@ function makeInit(): component_.page.Init< routeParams, proposal, opportunity, - [evaluation] + evaluation, + [] ]) as Msg; } ) From 076260c60718cb30f0b0315627b0efc5460148b2 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 16 Oct 2024 16:32:59 -0700 Subject: [PATCH 50/58] refactor: init with separate evaluation and panel evaluations --- .../lib/pages/proposal/sprint-with-us/view/index.tsx | 8 ++++++-- .../lib/pages/proposal/sprint-with-us/view/tab/index.ts | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx index ba6bf0073..d99df5265 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx @@ -49,6 +49,7 @@ export type InnerMsg_ = Tab.ParentInnerMsg< RouteParams, SWUProposal, SWUOpportunity, + SWUTeamQuestionResponseEvaluation | undefined, SWUTeamQuestionResponseEvaluation[] ] > @@ -104,6 +105,7 @@ function makeInit(): component_.page.Init< routeParams, proposal, opportunity, + undefined, [] ]) as Msg; } @@ -157,7 +159,8 @@ function makeComponent(): component_.page.Component< routeParams, proposal, opportunity, - questionEvaluations + questionEvaluation, + panelQuestionEvaluations ] = msg.value; // Set up the visible tab state. const tabId = routeParams.tab || "proposal"; @@ -172,7 +175,8 @@ function makeComponent(): component_.page.Component< viewerUser, proposal, opportunity, - questionEvaluations + questionEvaluation, + panelQuestionEvaluations }); // Everything checks out, return valid state. return [ diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts index b741c0700..8f928e9cf 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts @@ -34,7 +34,8 @@ export interface Params { proposal: SWUProposal; opportunity: SWUOpportunity; viewerUser: User; - questionEvaluations: SWUTeamQuestionResponseEvaluation[]; + questionEvaluation?: SWUTeamQuestionResponseEvaluation; + panelQuestionEvaluations: SWUTeamQuestionResponseEvaluation[]; } export type InitResponse = null; From 9e3a6bdd9cbf06cb3e458e7f979b7708cfa29afd Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 16 Oct 2024 16:33:34 -0700 Subject: [PATCH 51/58] feat: add toasts for draft creation and changes --- .../proposal/sprint-with-us/lib/toasts.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/toasts.ts b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/toasts.ts index a8f21b8e1..b2892fa81 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/toasts.ts +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/toasts.ts @@ -57,6 +57,28 @@ export const scored = { }) }; +export const questionEvaluationDraftCreated = { + success: { + title: "Draft Evaluation Saved", + body: "Your draft Sprint With Us evaluation has been saved. You can return to this page to modify your proposal prior to submission." + }, + error: { + title: "Unable to Save Draft Evaluation", + body: "Your draft Sprint With Us evaluation could not be saved. Please try again later." + } +}; + +export const questionEvaluationChangesSaved = { + success: { + title: "Evaluation Changes Saved", + body: "Your changes to your Sprint With Us evaluation have been saved." + }, + error: { + title: "Unable to Save Evaluation", + body: "Your changes to your Sprint With Us evaluation could not be saved. Please try again later." + } +}; + export const screenedIn = { success: { title: "Proposal Screened In", From 607754f56e439e60ee8248dd85e299ad1a7ce465 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 16 Oct 2024 16:34:30 -0700 Subject: [PATCH 52/58] refactor: allow null evaluation and remove unnecessary helper --- .../resources/question-evaluation/sprint-with-us.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/shared/lib/resources/question-evaluation/sprint-with-us.ts b/src/shared/lib/resources/question-evaluation/sprint-with-us.ts index 2ff20f826..e7312348f 100644 --- a/src/shared/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/shared/lib/resources/question-evaluation/sprint-with-us.ts @@ -59,20 +59,11 @@ export interface SWUTeamQuestionResponseEvaluation { updatedAt: Date; } -export function getEvaluationById( - evaluations: SWUTeamQuestionResponseEvaluation[], - id: Id -): SWUTeamQuestionResponseEvaluation | null { - return ( - evaluations.find((e) => e.evaluationPanelMember.user.id === id) ?? null - ); -} - export function getEvaluationScoreByOrder( - evaluation: SWUTeamQuestionResponseEvaluation, + evaluation: SWUTeamQuestionResponseEvaluation | null, order: number ): SWUTeamQuestionResponseEvaluationScores | null { - return evaluation.scores.find((s) => s.order === order) ?? null; + return evaluation?.scores.find((s) => s.order === order) ?? null; } // Create. From cef3f4251838dc6c1125a536a1422743d806eec6 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 16 Oct 2024 16:35:49 -0700 Subject: [PATCH 53/58] feat: team questions save draft and changes --- .../view/tab/team-questions.tsx | 791 ++++++++++-------- 1 file changed, 436 insertions(+), 355 deletions(-) diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx index 86b972a8d..68856ee30 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx @@ -1,7 +1,7 @@ import { EMPTY_STRING } from "front-end/config"; // import { makeStartLoading, makeStopLoading } from "front-end/lib"; import { Route } from "front-end/lib/app/types"; -// import * as FormField from "front-end/lib/components/form-field"; +import * as FormField from "front-end/lib/components/form-field"; import * as NumberField from "front-end/lib/components/form-field/number"; import * as LongText from "front-end/lib/components/form-field/long-text"; import { @@ -10,6 +10,7 @@ import { component as component_ } from "front-end/lib/framework"; import * as api from "front-end/lib/http/api"; +import * as toasts from "front-end/lib/pages/proposal/sprint-with-us/lib/toasts"; import ViewTabHeader from "front-end/lib/pages/proposal/sprint-with-us/lib/views/view-tab-header"; import * as Tab from "front-end/lib/pages/proposal/sprint-with-us/view/tab"; import Accordion from "front-end/lib/views/accordion"; @@ -19,30 +20,31 @@ import ReportCardList from "front-end/lib/views/report-card-list"; import Separator from "front-end/lib/views/separator"; import React from "react"; import { Alert, Col, Row } from "reactstrap"; -import { compareNumbers, countWords } from "shared/lib"; +import { countWords } from "shared/lib"; import { // canSWUOpportunityBeScreenedInToCodeChallenge, getQuestionByOrder, // hasSWUOpportunityPassedCodeChallenge, hasSWUOpportunityPassedTeamQuestions, hasSWUOpportunityPassedTeamQuestionsEvaluation, - SWUEvaluationPanelMember, SWUOpportunity, - SWUOpportunityStatus, - SWUTeamQuestion + SWUOpportunityStatus } from "shared/lib/resources/opportunity/sprint-with-us"; import { NUM_SCORE_DECIMALS, SWUProposal, + SWUProposalStatus, // SWUProposalStatus, - SWUProposalTeamQuestionResponse, - UpdateValidationErrors + SWUProposalTeamQuestionResponse } from "shared/lib/resources/proposal/sprint-with-us"; import { - getEvaluationById, + CreateValidationErrors, getEvaluationScoreByOrder, SWUTeamQuestionResponseEvaluation, - SWUTeamQuestionResponseEvaluationScores + SWUTeamQuestionResponseEvaluationScores, + SWUTeamQuestionResponseEvaluationStatus, + SWUTeamQuestionResponseEvaluationType, + UpdateValidationErrors } from "shared/lib/resources/question-evaluation/sprint-with-us"; import { adt, ADT } from "shared/lib/types"; import { invalid } from "shared/lib/validation"; @@ -50,203 +52,113 @@ import { validateSWUTeamQuestionResponseEvaluationScoreNotes, validateSWUTeamQuestionResponseEvaluationScoreScore } from "shared/lib/validation/question-evaluation/sprint-with-us"; +import { makeStartLoading, makeStopLoading } from "front-end/lib"; +import { leftPlacement, iconLinkSymbol } from "front-end/lib/views/link"; interface EvaluationScore { score: Immutable; notes: Immutable; } +type ModalId = ADT<"cancel">; + export interface State extends Tab.Params { - enterScoreLoading: number; + showModal: ModalId | null; + startEditingLoading: number; + saveLoading: number; screenToFromLoading: number; openAccordions: Set; - individualEvaluationScores: EvaluationScore[][]; - consensusEvaluationScores: EvaluationScore[]; + evaluationScores: EvaluationScore[]; + isEditing: boolean; } export type InnerMsg = | ADT<"toggleAccordion", number> - | ADT<"submitScore"> + | ADT<"showModal", ModalId> + | ADT<"saveDraft"> | ADT< - "onSubmitScoreResponse", - api.ResponseValidation + "onSaveDraftResponse", + api.ResponseValidation< + SWUTeamQuestionResponseEvaluation, + CreateValidationErrors + > + > + | ADT<"saveChanges"> + | ADT< + "onSaveChangesResponse", + api.ResponseValidation< + SWUTeamQuestionResponseEvaluation, + UpdateValidationErrors + > > | ADT<"screenIn"> | ADT<"onScreenInResponse", SWUProposal | null> | ADT<"screenOut"> | ADT<"onScreenOutResponse", SWUProposal | null> - | ADT< - "scoreMsgIndividual", - { childMsg: NumberField.Msg; rIndex: number; eIndex: number } - > - | ADT< - "notesMsgIndividual", - { childMsg: LongText.Msg; rIndex: number; eIndex: number } - > - | ADT<"scoreMsgConsensus", { childMsg: NumberField.Msg; rIndex: number }> - | ADT<"notesMsgConsensus", { childMsg: LongText.Msg; rIndex: number }>; + | ADT<"scoreMsg", { childMsg: NumberField.Msg; rIndex: number }> + | ADT<"notesMsg", { childMsg: LongText.Msg; rIndex: number }>; export type Msg = component_.page.Msg; -const initScore = ( - score: SWUTeamQuestionResponseEvaluationScores, - question: SWUTeamQuestion | null, - order: number, - rIndex: number -): [ - EvaluationScore, - [ReturnType[1], ReturnType[1]] -] => { - const [scoreState, scoreCmds] = NumberField.init({ - errors: [], - validate: (v) => { - if (v === null) { - return invalid(["Please enter a valid score."]); - } - return validateSWUTeamQuestionResponseEvaluationScoreScore( - v, - question?.score || 0 - ); - }, - child: { - step: 0.01, - value: - score.score === null || score.score === undefined ? null : score.score, - id: `swu-proposal-question-score-${order}-${rIndex}` - } - }); - const [notesState, notesCmds] = LongText.init({ - errors: [], - validate: validateSWUTeamQuestionResponseEvaluationScoreNotes, - child: { - value: score?.notes ?? "", - id: `swu-proposal-question-notes-${order}-${rIndex}` - } - }); - - return [ - { - score: immutable(scoreState), - notes: immutable(notesState) - }, - [scoreCmds, notesCmds] - ]; -}; - -function initIndividualScores( - opp: SWUOpportunity, - prop: SWUProposal, - evaluators: SWUEvaluationPanelMember[], - evaluations: SWUTeamQuestionResponseEvaluation[] -): [EvaluationScore[][], component_.Cmd[]] { - return (prop.teamQuestionResponses || []).reduce( - ([states, cmds], r, rIndex) => { - // Gets the opp by response order - const question = getQuestionByOrder(opp, r.order); - - const [questionScoreStates, questionScoreCmds] = evaluators.reduce< - component_.base.InitReturnValue - >( - ([scoreStates, scoreCmds], epm, eIndex) => { - const evaluation = getEvaluationById(evaluations, epm.user.id); - if (!evaluation) { - return [scoreStates, scoreCmds]; - } - const score = getEvaluationScoreByOrder(evaluation, r.order); - if (!score) { - return [scoreStates, scoreCmds]; - } - - const [scoreState, [scoreScoreCmds, scoreNotesCmds]] = initScore( - score, - question, - epm.order, - rIndex - ); - - return [ - [...scoreStates, scoreState], - [ - ...scoreCmds, - ...[ - ...component_.cmd.mapMany( - scoreScoreCmds, - (childMsg) => - adt("scoreMsgIndividual", { - rIndex, - eIndex, - childMsg - }) as Msg - ), - ...component_.cmd.mapMany( - scoreNotesCmds, - (childMsg) => - adt("notesMsgIndividual", { - rIndex, - eIndex, - childMsg - }) as Msg - ) - ] - ] - ]; - }, - [[], []] - ); - - return [ - [...states, questionScoreStates], - [...cmds, ...questionScoreCmds] - ]; - }, - [[], []] as [EvaluationScore[][], component_.Cmd[]] - ); -} - -function initConsensusScores( +function initEvaluationScores( opp: SWUOpportunity, prop: SWUProposal, - chair: SWUEvaluationPanelMember, - evaluations: SWUTeamQuestionResponseEvaluation[] + evaluation?: SWUTeamQuestionResponseEvaluation ): [EvaluationScore[], component_.Cmd[]] { - return (prop.teamQuestionResponses || []).reduce< + return prop.teamQuestionResponses.reduce< [EvaluationScore[], component_.Cmd[]] >( ([states, cmds], r, rIndex) => { const question = getQuestionByOrder(opp, r.order); + const score = evaluation + ? getEvaluationScoreByOrder(evaluation, r.order) + : null; - const evaluation = getEvaluationById(evaluations, chair.user.id); - if (!evaluation) { - return [states, cmds]; - } - const score = getEvaluationScoreByOrder(evaluation, r.order); - if (!score) { - return [states, cmds]; - } - - const [scoreState, [scoreScoreCmds, scoreNotesCmds]] = initScore( - score, - question, - chair.order, - rIndex - ); + const [scoreState, scoreCmds] = NumberField.init({ + errors: [], + validate: (v) => { + if (v === null) { + return invalid(["Please enter a valid score."]); + } + return validateSWUTeamQuestionResponseEvaluationScoreScore( + v, + question?.score || 0 + ); + }, + child: { + step: 0.01, + value: score?.score ?? null, + id: `swu-proposal-question-evaluation-score-${rIndex}` + } + }); + const [notesState, notesCmds] = LongText.init({ + errors: [], + validate: validateSWUTeamQuestionResponseEvaluationScoreNotes, + child: { + value: score?.notes ?? "", + id: `swu-proposal-question-evaluation-notes-${rIndex}` + } + }); return [ - [...states, scoreState], + [ + ...states, + { score: immutable(scoreState), notes: immutable(notesState) } + ], [ ...cmds, ...component_.cmd.mapMany( - scoreScoreCmds, + scoreCmds, (childMsg) => - adt("scoreMsgConsensus", { + adt("scoreMsg", { rIndex, childMsg }) as Msg ), ...component_.cmd.mapMany( - scoreNotesCmds, + notesCmds, (childMsg) => - adt("notesMsgConsensus", { + adt("notesMsg", { rIndex, childMsg }) as Msg @@ -259,43 +171,32 @@ function initConsensusScores( } export const init: component_.base.Init = (params) => { - const sortedEvaluators = - params.opportunity.evaluationPanel?.sort((a, b) => - compareNumbers(a.order, b.order) - ) ?? []; - const chair = sortedEvaluators.filter((epm) => epm.chair)[0]; - const [individualScoreStates, individualScoreCmds] = initIndividualScores( - params.opportunity, - params.proposal, - sortedEvaluators.filter((epm) => epm.evaluator), - params.questionEvaluations - ); - const [consensusScoreStates, consensusScoreCmds] = initConsensusScores( + const [evaluationScoreStates, evaluationScoreCmds] = initEvaluationScores( params.opportunity, params.proposal, - chair, - params.questionEvaluations + params.questionEvaluation ); return [ { ...params, showModal: null, screenToFromLoading: 0, - enterScoreLoading: 0, + saveLoading: 0, + startEditingLoading: 0, openAccordions: new Set( params.proposal.teamQuestionResponses.map((p, i) => i) ), - individualEvaluationScores: individualScoreStates, - consensusEvaluationScores: consensusScoreStates + evaluationScores: evaluationScoreStates, + isEditing: !params.questionEvaluation }, - [...individualScoreCmds, ...consensusScoreCmds] + evaluationScoreCmds ]; }; -// const startScreenToFromLoading = makeStartLoading("screenToFromLoading"); -// const stopScreenToFromLoading = makeStopLoading("screenToFromLoading"); -// const startEnterScoreLoading = makeStartLoading("enterScoreLoading"); -// const stopEnterScoreLoading = makeStopLoading("enterScoreLoading"); +const startSaveLoading = makeStartLoading("saveLoading"); +const stopSaveLoading = makeStopLoading("saveLoading"); +// const startEditingLoading = makeStartLoading("startEditingLoading"); +// const stopEditingLoading = makeStopLoading("startEditingLoading"); const update: component_.base.Update = ({ state, msg }) => { switch (msg.tag) { @@ -311,101 +212,192 @@ const update: component_.base.Update = ({ state, msg }) => { }), [] ]; - // case "submitScore": { - // const scores = state.proposal.teamQuestionResponses.reduce( - // (acc, r, i) => { - // if (!acc) { - // return null; - // } - // const field = state.scores[i]; - // if (!field) { - // return null; - // } - // const score = FormField.getValue(field); - // if (score === null) { - // return null; - // } - // acc.push({ - // order: r.order, - // score - // }); - // return acc; - // }, - // [] as UpdateTeamQuestionScoreBody[] | null - // ); - // if (scores === null) { - // return [state, []]; - // } - // return [ - // startEnterScoreLoading(state), - // [ - // api.proposals.swu.update()( - // state.proposal.id, - // adt("scoreQuestions", scores), - // (response) => adt("onSubmitScoreResponse", response) - // ) - // ] - // ]; - // } - // case "onSubmitScoreResponse": { - // state = stopEnterScoreLoading(state); - // const result = msg.value; - // switch (result.tag) { - // case "valid": { - // const [scoreStates, scoreCmds] = initScores( - // state.opportunity, - // result.value - // ); - // return [ - // state - // .set("scores", scoreStates) - // .set("showModal", null) - // .set("proposal", result.value), - // [ - // ...scoreCmds, - // component_.cmd.dispatch( - // component_.global.showToastMsg( - // adt("success", toasts.scored.success("Team Questions")) - // ) - // ) - // ] - // ]; - // } - // case "invalid": { - // let scores = state.scores; - // if ( - // result.value.proposal && - // result.value.proposal.tag === "scoreQuestions" - // ) { - // scores = result.value.proposal.value.map((e, i) => - // FormField.setErrors(scores[i], e.score || []) - // ); - // } - // return [ - // state.set("scores", scores), - // [ - // component_.cmd.dispatch( - // component_.global.showToastMsg( - // adt("error", toasts.scored.error("Team Questions")) - // ) - // ) - // ] - // ]; - // } - // case "unhandled": - // default: - // return [ - // state, - // [ - // component_.cmd.dispatch( - // component_.global.showToastMsg( - // adt("error", toasts.scored.error("Team Questions")) - // ) - // ) - // ] - // ]; - // } - // } + case "saveDraft": { + const scores = getValues(state); + if (scores === null) { + return [state, []]; + } + + return [ + startSaveLoading(state), + [ + api.evaluations.swu.create()( + { + proposal: state.proposal.id, + type: + state.proposal.status === + SWUProposalStatus.TeamQuestionsPanelIndividual + ? SWUTeamQuestionResponseEvaluationType.Individual + : SWUTeamQuestionResponseEvaluationType.Consensus, + status: SWUTeamQuestionResponseEvaluationStatus.Draft, + scores + }, + (response) => adt("onSaveDraftResponse", response) + ) + ] + ]; + } + case "onSaveDraftResponse": { + state = stopSaveLoading(state); + const result = msg.value; + switch (result.tag) { + case "valid": { + const [evaluationScoreStates, evaluationScoreCmds] = + initEvaluationScores( + state.opportunity, + state.proposal, + result.value + ); + return [ + state.set("evaluationScores", evaluationScoreStates), + [ + ...evaluationScoreCmds, + component_.cmd.dispatch( + component_.global.newRouteMsg( + adt( + result.value.type === + SWUTeamQuestionResponseEvaluationType.Individual + ? "questionEvaluationIndividualSWUEdit" + : "questionEvaluationConsensusSWUEdit", + { + proposalId: state.proposal.id, + opportunityId: state.proposal.opportunity.id, + evaluationId: result.value.id, + tab: "teamQuestions" as const + } + ) as Route + ) + ), + component_.cmd.dispatch( + component_.global.showToastMsg( + adt("success", toasts.questionEvaluationDraftCreated.success) + ) + ) + ] + ]; + } + case "invalid": { + let evaluationScores = state.evaluationScores; + if (result.value) { + evaluationScores = (result.value.scores ?? []).map((e, i) => ({ + notes: FormField.setErrors( + evaluationScores[i].notes, + e.notes || [] + ), + score: FormField.setErrors( + evaluationScores[i].score, + e.score || [] + ) + })); + } + return [ + state.set("evaluationScores", evaluationScores), + [ + component_.cmd.dispatch( + component_.global.showToastMsg( + adt("error", toasts.questionEvaluationDraftCreated.error) + ) + ) + ] + ]; + } + case "unhandled": + default: + return [ + state, + [ + component_.cmd.dispatch( + component_.global.showToastMsg( + adt("error", toasts.questionEvaluationDraftCreated.error) + ) + ) + ] + ]; + } + } + case "saveChanges": { + const scores = getValues(state); + if (scores === null) { + return [state, []]; + } + + return [ + startSaveLoading(state), + state.questionEvaluation + ? [ + api.evaluations.swu.update()( + state.questionEvaluation.id, + adt("edit", { scores }), + (response) => adt("onSaveDraftResponse", response) + ) + ] + : [] + ]; + } + case "onSaveChangesResponse": { + state = stopSaveLoading(state); + const result = msg.value; + switch (result.tag) { + case "valid": { + const [evaluationScoreStates, evaluationScoreCmds] = + initEvaluationScores( + state.opportunity, + state.proposal, + result.value + ); + return [ + state.set("evaluationScores", evaluationScoreStates), + [ + ...evaluationScoreCmds, + component_.cmd.dispatch( + component_.global.showToastMsg( + adt("success", toasts.questionEvaluationChangesSaved.success) + ) + ) + ] + ]; + } + case "invalid": { + let evaluationScores = state.evaluationScores; + if (result.value.evaluation?.tag === "edit") { + evaluationScores = (result.value.evaluation.value.scores ?? []).map( + (e, i) => ({ + notes: FormField.setErrors( + evaluationScores[i].notes, + e.notes || [] + ), + score: FormField.setErrors( + evaluationScores[i].score, + e.score || [] + ) + }) + ); + } + return [ + state.set("evaluationScores", evaluationScores), + [ + component_.cmd.dispatch( + component_.global.showToastMsg( + adt("error", toasts.questionEvaluationChangesSaved.error) + ) + ) + ] + ]; + } + case "unhandled": + default: + return [ + state, + [ + component_.cmd.dispatch( + component_.global.showToastMsg( + adt("error", toasts.questionEvaluationChangesSaved.error) + ) + ) + ] + ]; + } + } // case "screenIn": // return [ // startScreenToFromLoading(state).set("showModal", null), @@ -482,70 +474,26 @@ const update: component_.base.Update = ({ state, msg }) => { // return [state, []]; // } // } - case "scoreMsgIndividual": + case "scoreMsg": return component_.base.updateChild({ state, - childStatePath: [ - "individualEvaluationScores", - String(msg.value.rIndex), - String(msg.value.eIndex), - "score" - ], + childStatePath: ["evaluationScores", String(msg.value.rIndex), "score"], childUpdate: NumberField.update, childMsg: msg.value.childMsg, mapChildMsg: (value) => - adt("scoreMsgIndividual", { + adt("scoreMsg", { rIndex: msg.value.rIndex, - eIndex: msg.value.eIndex, childMsg: value }) as Msg }); - case "notesMsgIndividual": + case "notesMsg": return component_.base.updateChild({ state, - childStatePath: [ - "individualEvaluationScores", - String(msg.value.rIndex), - String(msg.value.eIndex), - "notes" - ], + childStatePath: ["evaluationScores", String(msg.value.rIndex), "notes"], childUpdate: LongText.update, childMsg: msg.value.childMsg, mapChildMsg: (value) => - adt("notesMsgIndividual", { - rIndex: msg.value.rIndex, - eIndex: msg.value.eIndex, - childMsg: value - }) as Msg - }); - case "scoreMsgConsensus": - return component_.base.updateChild({ - state, - childStatePath: [ - "consensusEvaluationScores", - String(msg.value.rIndex), - "score" - ], - childUpdate: NumberField.update, - childMsg: msg.value.childMsg, - mapChildMsg: (value) => - adt("scoreMsgConsensus", { - rIndex: msg.value.rIndex, - childMsg: value - }) as Msg - }); - case "notesMsgConsensus": - return component_.base.updateChild({ - state, - childStatePath: [ - "consensusEvaluationScores", - String(msg.value.rIndex), - "notes" - ], - childUpdate: LongText.update, - childMsg: msg.value.childMsg, - mapChildMsg: (value) => - adt("notesMsgConsensus", { + adt("notesMsg", { rIndex: msg.value.rIndex, childMsg: value }) as Msg @@ -555,6 +503,34 @@ const update: component_.base.Update = ({ state, msg }) => { } }; +export function getValues( + state: Immutable +): SWUTeamQuestionResponseEvaluationScores[] | null { + return state.proposal.teamQuestionResponses.reduce((acc, r, i) => { + if (!acc) { + return null; + } + const field = state.evaluationScores[i]; + if (!field) { + return null; + } + const score = FormField.getValue(field.score); + if (score === null) { + return null; + } + const notes = FormField.getValue(field.notes); + if (notes === null) { + return null; + } + acc.push({ + order: r.order, + score, + notes + }); + return acc; + }, [] as SWUTeamQuestionResponseEvaluationScores[] | null); +} + interface TeamQuestionResponseViewProps { opportunity: SWUOpportunity; response: SWUProposalTeamQuestionResponse; @@ -663,9 +639,36 @@ const TeamQuestionResponsesView: component_.base.View<{ ); }; +// interface TeamQuestionResponseEvalViewProps +// extends TeamQuestionResponseViewProps { +// individualScores: EvaluationScore[]; +// consensusScore: EvaluationScore; +// } + +// const TeamQuestionsResponseEvalIndividualView: component_.base.View< +// TeamQuestionResponseEvalViewProps +// > = () + +interface TeamQuestionResponseEvalViewProps + extends Omit { + score: EvaluationScore; + proposal: SWUProposal; + dispatch: component_.base.Dispatch; + // panelEvaluationScores: SWUTeamQuestionResponseEvaluation[] +} + const TeamQuestionResponseEvalView: component_.base.View< - TeamQuestionResponseViewProps -> = ({ opportunity, response, index, isOpen, className, toggleAccordion }) => { + TeamQuestionResponseEvalViewProps +> = ({ + opportunity, + response, + index, + isOpen, + className, + dispatch, + proposal, + score +}) => { const question = getQuestionByOrder(opportunity, response.order); if (!question) { return null; @@ -673,7 +676,7 @@ const TeamQuestionResponseEvalView: component_.base.View< return ( toggleAccordion()} + toggle={() => dispatch(adt("toggleAccordion", index))} color="info" title={`Question ${index + 1}`} titleClassName="h3 mb-0" @@ -702,7 +705,46 @@ const TeamQuestionResponseEvalView: component_.base.View<
{question.guideline}
- +
+ +
+ {proposal.status === SWUProposalStatus.TeamQuestionsPanelIndividual ? ( + + + + adt("notesMsg" as const, { + childMsg: value, + rIndex: index + }) + )} + /> + + + + adt("scoreMsg" as const, { + childMsg: value, + rIndex: index + }) + )} + /> + + + ) : ( +
+ )} ); }; @@ -744,13 +786,16 @@ const TeamQuestionResponsesEvalView: component_.base.View<{

Team Questions{"'"} Responses

{state.proposal.teamQuestionResponses.map((r, i, rs) => ( dispatch(adt("toggleAccordion", i))} index={i} response={r} + score={state.evaluationScores[i]} + // panelEvaluations={state.panelEvaluations} + proposal={state.proposal} + dispatch={dispatch} /> ))}
@@ -776,12 +821,12 @@ const view: component_.base.ComponentView = ({ ); }; -// function isValid(state: Immutable): boolean { -// return state.scores.reduce( -// (acc, s) => acc && FormField.isValid(s), -// true as boolean -// ); -// } +function isValid(state: Immutable): boolean { + return state.evaluationScores.reduce( + (acc, s) => acc && FormField.isValid(s.notes) && FormField.isValid(s.score), + true as boolean + ); +} export const component: Tab.Component = { init, @@ -848,21 +893,57 @@ export const component: Tab.Component = { // } // }, - getActions: ({ state }) => { + getActions: ({ state, dispatch }) => { const proposal = state.proposal; const propStatus = proposal.status; - // const isScreenToFromLoading = state.screenToFromLoading > 0; + const isSaveLoading = state.saveLoading > 0; + const valid = isValid(state); + // if (state.evaluation && state.isEditing) { + // } switch (propStatus) { - // case SWUProposalStatus.UnderReviewTeamQuestions: - // return component_.page.actions.links([ - // { - // children: "Enter Score", - // symbol_: leftPlacement(iconLinkSymbol("star-full")), - // button: true, - // color: "primary", - // onClick: () => dispatch(adt("showModal", "enterScore" as const)) - // } - // ]); + case SWUProposalStatus.TeamQuestionsPanelIndividual: + return component_.page.actions.links( + state.questionEvaluation + ? state.questionEvaluation.status === + SWUTeamQuestionResponseEvaluationStatus.Draft + ? [ + { + children: "Save Changes", + symbol_: leftPlacement(iconLinkSymbol("save")), + loading: isSaveLoading, + disabled: isSaveLoading || !valid, + button: true, + color: "success", + onClick: () => dispatch(adt("saveChanges")) + }, + { + children: "Cancel", + color: "c-nav-fg-alt", + disabled: isSaveLoading, + onClick: () => + dispatch(adt("showModal", adt("cancel")) as Msg) + } + ] + : [] + : [ + { + children: "Save Draft", + symbol_: leftPlacement(iconLinkSymbol("save")), + loading: isSaveLoading, + disabled: isSaveLoading || !valid, + button: true, + color: "success", + onClick: () => dispatch(adt("saveDraft")) + }, + { + children: "Cancel", + color: "c-nav-fg-alt", + disabled: isSaveLoading, + onClick: () => + dispatch(adt("showModal", adt("cancel")) as Msg) + } + ] + ); // case SWUProposalStatus.EvaluatedTeamQuestions: // return component_.page.actions.links([ // ...(canSWUOpportunityBeScreenedInToCodeChallenge(state.opportunity) From 6ec9b7c3009ca6c1bb6beb23b29196f44c1e97c9 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Thu, 17 Oct 2024 14:01:35 -0700 Subject: [PATCH 54/58] fix: use correct condition for read many operations --- .../lib/resources/question-evaluation/sprint-with-us.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts index 1901b55f8..47e032bee 100644 --- a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts @@ -73,7 +73,7 @@ const readMany: crud.ReadMany = ( code: number, body: SWUTeamQuestionResponseEvaluation[] | string[] ) => basicResponse(code, request.session, makeJsonResponseBody(body)); - if (request.query.opportunity) { + if (request.query.proposal) { if (!permissions.isSignedIn(request.session)) { return respond(401, [permissions.ERROR_MESSAGE]); } @@ -106,7 +106,7 @@ const readMany: crud.ReadMany = ( return respond(503, [db.ERROR_MESSAGE]); } return respond(200, dbResult.value); - } else if (request.query.proposal) { + } else if (request.query.opportunity) { if (!permissions.isSignedIn(request.session)) { return respond(401, [permissions.ERROR_MESSAGE]); } From 8e51a0459413f98ed215bcc252d6a46e25475880 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Thu, 17 Oct 2024 14:02:18 -0700 Subject: [PATCH 55/58] fix: display existing evaluations in overview --- .../opportunity/sprint-with-us/edit/tab/overview.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx index fc4ba4ba9..82f931e5d 100644 --- a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx +++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx @@ -242,7 +242,7 @@ const ContextMenuCell: component_.base.View<{ evaluationId: evaluation.id }) )}> - {evaluation ? "Edit" : "Start Evaluation"} + Edit ) : ( - {evaluation ? "Edit" : "Start Evaluation"} + Start Evaluation ); }; @@ -311,12 +311,12 @@ function evaluationTableBodyRows(state: Immutable): Table.BodyRows { ) }, ...opportunity.teamQuestions.map((tq) => { - const score = evaluation?.scores[tq.order].score; + const score = evaluation?.scores[tq.order]?.score; return { className: "text-center", children: (
- {score ? `${score.toFixed(NUM_SCORE_DECIMALS)}%` : EMPTY_STRING} + {score ? `${score.toFixed(NUM_SCORE_DECIMALS)}` : EMPTY_STRING}
) }; From 13551a94f9970462a340021d75985582372a7622 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Fri, 18 Oct 2024 17:05:34 -0700 Subject: [PATCH 56/58] feat: cancel drafts --- .../view/tab/team-questions.tsx | 128 ++++++++++-------- 1 file changed, 71 insertions(+), 57 deletions(-) diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx index 68856ee30..236220338 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx @@ -60,7 +60,7 @@ interface EvaluationScore { notes: Immutable; } -type ModalId = ADT<"cancel">; +type ModalId = ADT<"cancelDraft">; export interface State extends Tab.Params { showModal: ModalId | null; @@ -75,6 +75,8 @@ export interface State extends Tab.Params { export type InnerMsg = | ADT<"toggleAccordion", number> | ADT<"showModal", ModalId> + | ADT<"hideModal"> + | ADT<"cancel"> | ADT<"saveDraft"> | ADT< "onSaveDraftResponse", @@ -212,6 +214,36 @@ const update: component_.base.Update = ({ state, msg }) => { }), [] ]; + case "showModal": + return [state.set("showModal", msg.value), []]; + case "hideModal": + if (state.saveLoading > 0) { + return [state, []]; + } + return [state.set("showModal", null), []]; + case "cancel": + return [ + state, + [ + component_.cmd.dispatch( + component_.global.newRouteMsg( + adt("opportunitySWUEdit" as const, { + opportunityId: state.opportunity.id, + tab: (() => { + switch (state.proposal.status) { + case SWUProposalStatus.TeamQuestionsPanelIndividual: + return "overview" as const; + case SWUProposalStatus.TeamQuestionsPanelConsensus: + return "consensus" as const; + default: + return "teamQuestions" as const; + } + })() + }) + ) + ) + ] + ]; case "saveDraft": { const scores = getValues(state); if (scores === null) { @@ -828,6 +860,14 @@ function isValid(state: Immutable): boolean { ); } +// component_.page.Component< +// RouteParams, +// SharedState, +// State, +// InnerMsg, +// Route +// > + export const component: Tab.Component = { init, update, @@ -837,61 +877,35 @@ export const component: Tab.Component = { return component_.page.readyMsg(); }, - // getModal: (state) => { - // const isEnterScoreLoading = state.enterScoreLoading > 0; - // const valid = isValid(state); - // switch (state.showModal) { - // case "enterScore": - // return component_.page.modal.show({ - // title: "Enter Score", - // onCloseMsg: adt("hideModal") as Msg, - // actions: [ - // { - // text: "Submit Score", - // icon: "star-full", - // color: "primary", - // button: true, - // loading: isEnterScoreLoading, - // disabled: isEnterScoreLoading || !valid, - // msg: adt("submitScore") - // }, - // { - // text: "Cancel", - // color: "secondary", - // disabled: isEnterScoreLoading, - // msg: adt("hideModal") - // } - // ], - // body: (dispatch) => ( - //
- //

- // Provide a score for each team question response submitted by the - // proponent. - //

- // {state.scores.map((s, i) => { - // return ( - // adt("scoreMsg" as const, [i, v]) as Msg - // )} - // state={s} - // /> - // ); - // })} - //
- // ) - // }); - // case null: - // return component_.page.modal.hide(); - // } - // }, + getModal: (state) => { + if (!state.showModal) { + return component_.page.modal.hide(); + } + switch (state.showModal.tag) { + case "cancelDraft": + return component_.page.modal.show({ + title: "Cancel New Sprint With Us Evaluation?", + body: () => + "Are you sure you want to cancel? Any information you may have entered will be lost if you do so.", + onCloseMsg: adt("hideModal"), + actions: [ + { + text: "Yes, I want to cancel", + color: "danger", + msg: adt("cancel"), + button: true + }, + { + text: "Go Back", + color: "secondary", + msg: adt("hideModal") + } + ] + }); + case null: + return component_.page.modal.hide(); + } + }, getActions: ({ state, dispatch }) => { const proposal = state.proposal; @@ -940,7 +954,7 @@ export const component: Tab.Component = { color: "c-nav-fg-alt", disabled: isSaveLoading, onClick: () => - dispatch(adt("showModal", adt("cancel")) as Msg) + dispatch(adt("showModal", adt("cancelDraft")) as Msg) } ] ); From 97bb1754d608a0fb79c33a4a926662cd5941d771 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Fri, 18 Oct 2024 17:05:53 -0700 Subject: [PATCH 57/58] fix: proposal typo --- .../typescript/lib/pages/proposal/sprint-with-us/lib/toasts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/toasts.ts b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/toasts.ts index b2892fa81..fc2affc56 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/toasts.ts +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/toasts.ts @@ -60,7 +60,7 @@ export const scored = { export const questionEvaluationDraftCreated = { success: { title: "Draft Evaluation Saved", - body: "Your draft Sprint With Us evaluation has been saved. You can return to this page to modify your proposal prior to submission." + body: "Your draft Sprint With Us evaluation has been saved. You can return to this page to modify your evaluation prior to submission." }, error: { title: "Unable to Save Draft Evaluation", From 693eed0ab532be24ab1f8464ee08cacebdba3538 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Fri, 25 Oct 2024 11:27:35 -0700 Subject: [PATCH 58/58] refactor: team questions proposal tab to opportunity tab navigation --- .../proposal/sprint-with-us/view/index.tsx | 11 +- .../proposal/sprint-with-us/view/tab/index.ts | 6 +- .../view/tab/team-questions.tsx | 186 ++++++++++++------ .../sprint-with-us/create-consensus.tsx | 1 + .../sprint-with-us/create-individual.tsx | 1 + .../sprint-with-us/edit-consensus.tsx | 1 + .../sprint-with-us/edit-individual.tsx | 1 + 7 files changed, 146 insertions(+), 61 deletions(-) diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx index d99df5265..ad6160a97 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx @@ -27,6 +27,7 @@ import { adt, ADT, Id } from "shared/lib/types"; import { invalid, valid, Validation } from "shared/lib/validation"; import { SWUOpportunity } from "shared/lib/resources/opportunity/sprint-with-us"; import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; +import { getTeamQuestionsOpportunityTab } from "./tab/team-questions"; interface ValidState extends Tab.ParentState { proposal: SWUProposal | null; @@ -49,6 +50,7 @@ export type InnerMsg_ = Tab.ParentInnerMsg< RouteParams, SWUProposal, SWUOpportunity, + boolean, SWUTeamQuestionResponseEvaluation | undefined, SWUTeamQuestionResponseEvaluation[] ] @@ -105,6 +107,7 @@ function makeInit(): component_.page.Init< routeParams, proposal, opportunity, + false, undefined, [] ]) as Msg; @@ -159,6 +162,7 @@ function makeComponent(): component_.page.Component< routeParams, proposal, opportunity, + evaluating, questionEvaluation, panelQuestionEvaluations ] = msg.value; @@ -167,7 +171,11 @@ function makeComponent(): component_.page.Component< // Initialize the sidebar. const [sidebarState, sidebarCmds] = Tab.makeSidebarState( tabId, - proposal + proposal, + getTeamQuestionsOpportunityTab( + evaluating, + panelQuestionEvaluations + ) ); // Initialize the tab. const tabComponent = Tab.idToDefinition(tabId).component; @@ -175,6 +183,7 @@ function makeComponent(): component_.page.Component< viewerUser, proposal, opportunity, + evaluating, questionEvaluation, panelQuestionEvaluations }); diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts index 8f928e9cf..db001c073 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts @@ -34,6 +34,7 @@ export interface Params { proposal: SWUProposal; opportunity: SWUOpportunity; viewerUser: User; + evaluating: boolean; questionEvaluation?: SWUTeamQuestionResponseEvaluation; panelQuestionEvaluations: SWUTeamQuestionResponseEvaluation[]; } @@ -159,7 +160,8 @@ export function makeSidebarLink( export function makeSidebarState( activeTab: TabId, - proposal: SWUProposal + proposal: SWUProposal, + teamQuestionsTab: "consensus" | "overview" | "teamQuestions" ): component.base.InitReturnValue { return MenuSidebar.init({ backLink: { @@ -173,7 +175,7 @@ export function makeSidebarState( case "teamScenario": return "teamScenario" as const; case "teamQuestions": - return "teamQuestions" as const; + return teamQuestionsTab; case "proposal": case "history": default: diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx index 236220338..93ad81307 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx @@ -27,8 +27,7 @@ import { // hasSWUOpportunityPassedCodeChallenge, hasSWUOpportunityPassedTeamQuestions, hasSWUOpportunityPassedTeamQuestionsEvaluation, - SWUOpportunity, - SWUOpportunityStatus + SWUOpportunity } from "shared/lib/resources/opportunity/sprint-with-us"; import { NUM_SCORE_DECIMALS, @@ -76,6 +75,12 @@ export type InnerMsg = | ADT<"toggleAccordion", number> | ADT<"showModal", ModalId> | ADT<"hideModal"> + | ADT<"startEditing"> + | ADT< + "onStartEditingResponse", + api.ResponseValidation + > + | ADT<"cancelEditing"> | ADT<"cancel"> | ADT<"saveDraft"> | ADT< @@ -102,6 +107,17 @@ export type InnerMsg = export type Msg = component_.page.Msg; +export function getTeamQuestionsOpportunityTab( + evaluating: boolean, + panelEvaluations: SWUTeamQuestionResponseEvaluation[] +) { + return evaluating + ? panelEvaluations.length + ? ("consensus" as const) + : ("overview" as const) + : ("teamQuestions" as const); +} + function initEvaluationScores( opp: SWUOpportunity, prop: SWUProposal, @@ -197,8 +213,8 @@ export const init: component_.base.Init = (params) => { const startSaveLoading = makeStartLoading("saveLoading"); const stopSaveLoading = makeStopLoading("saveLoading"); -// const startEditingLoading = makeStartLoading("startEditingLoading"); -// const stopEditingLoading = makeStopLoading("startEditingLoading"); +const startStartEditingLoading = makeStartLoading("startEditingLoading"); +const stopStartEditingLoading = makeStopLoading("startEditingLoading"); const update: component_.base.Update = ({ state, msg }) => { switch (msg.tag) { @@ -221,6 +237,53 @@ const update: component_.base.Update = ({ state, msg }) => { return [state, []]; } return [state.set("showModal", null), []]; + case "startEditing": { + const evaluation = state.questionEvaluation; + if (!evaluation) return [state, []]; + return [ + startStartEditingLoading(state), + [ + api.evaluations.swu.readOne()(evaluation.id, (result) => + adt("onStartEditingResponse", result) + ) + ] + ]; + } + case "onStartEditingResponse": { + const evaluation = state.questionEvaluation; + if (!evaluation) return [state, []]; + const evaluationResult = msg.value; + state = stopStartEditingLoading(state); + if (!api.isValid(evaluationResult)) { + return [state, []]; + } + const [evaluationScoreStates, evaluationScoreCmds] = initEvaluationScores( + state.opportunity, + state.proposal, + evaluationResult.value + ); + return [ + state + .set("isEditing", true) + .set("evaluationScores", evaluationScoreStates), + evaluationScoreCmds + ]; + } + case "cancelEditing": { + const evaluation = state.evaluationScores; + if (!evaluation) return [state, []]; + const [evaluationScoreStates, evaluationScoreCmds] = initEvaluationScores( + state.opportunity, + state.proposal, + state.questionEvaluation + ); + return [ + state + .set("isEditing", false) + .set("evaluationScores", evaluationScoreStates), + evaluationScoreCmds + ]; + } case "cancel": return [ state, @@ -229,16 +292,11 @@ const update: component_.base.Update = ({ state, msg }) => { component_.global.newRouteMsg( adt("opportunitySWUEdit" as const, { opportunityId: state.opportunity.id, - tab: (() => { - switch (state.proposal.status) { - case SWUProposalStatus.TeamQuestionsPanelIndividual: - return "overview" as const; - case SWUProposalStatus.TeamQuestionsPanelConsensus: - return "consensus" as const; - default: - return "teamQuestions" as const; - } - })() + tab: (() => + getTeamQuestionsOpportunityTab( + state.evaluating, + state.panelQuestionEvaluations + ))() }) ) ) @@ -686,6 +744,7 @@ interface TeamQuestionResponseEvalViewProps score: EvaluationScore; proposal: SWUProposal; dispatch: component_.base.Dispatch; + disabled: boolean; // panelEvaluationScores: SWUTeamQuestionResponseEvaluation[] } @@ -699,7 +758,8 @@ const TeamQuestionResponseEvalView: component_.base.View< className, dispatch, proposal, - score + score, + disabled }) => { const question = getQuestionByOrder(opportunity, response.order); if (!question) { @@ -748,7 +808,7 @@ const TeamQuestionResponseEvalView: component_.base.View< extraChildProps={{ style: { height: "200px" } }} - // disabled={disabled} + disabled={disabled} state={score.notes} dispatch={component_.base.mapDispatch(dispatch, (value) => adt("notesMsg" as const, { @@ -763,7 +823,7 @@ const TeamQuestionResponseEvalView: component_.base.View< extraChildProps={{ suffix: "Points" }} label="Score" hint="hint" - // disabled={disabled} + disabled={disabled} state={score.score} dispatch={component_.base.mapDispatch(dispatch, (value) => adt("scoreMsg" as const, { @@ -788,6 +848,9 @@ const TeamQuestionResponsesEvalView: component_.base.View<{ const show = hasSWUOpportunityPassedTeamQuestionsEvaluation( state.opportunity ); + const isStartEditingLoading = state.startEditingLoading > 0; + const isSaveLoading = state.saveLoading > 0; + const isLoading = isStartEditingLoading || isSaveLoading; return (
@@ -825,9 +888,9 @@ const TeamQuestionResponsesEvalView: component_.base.View<{ index={i} response={r} score={state.evaluationScores[i]} - // panelEvaluations={state.panelEvaluations} proposal={state.proposal} dispatch={dispatch} + disabled={!state.isEditing || isLoading} /> ))}
@@ -845,8 +908,7 @@ const view: component_.base.ComponentView = ({ state, dispatch }) => { - return state.opportunity.status === - SWUOpportunityStatus.EvaluationTeamQuestionsPanel ? ( + return state.evaluating ? ( ) : ( @@ -860,14 +922,6 @@ function isValid(state: Immutable): boolean { ); } -// component_.page.Component< -// RouteParams, -// SharedState, -// State, -// InnerMsg, -// Route -// > - export const component: Tab.Component = { init, update, @@ -911,52 +965,68 @@ export const component: Tab.Component = { const proposal = state.proposal; const propStatus = proposal.status; const isSaveLoading = state.saveLoading > 0; + const isStartEditingLoading = state.startEditingLoading > 0; + const isLoading = isSaveLoading || isStartEditingLoading; const valid = isValid(state); - // if (state.evaluation && state.isEditing) { - // } + if (state.isEditing && state.questionEvaluation) { + return component_.page.actions.links([ + { + children: "Save Changes", + symbol_: leftPlacement(iconLinkSymbol("save")), + loading: isSaveLoading, + disabled: isLoading || !valid, + button: true, + color: "success", + onClick: () => dispatch(adt("saveChanges")) + }, + { + children: "Cancel", + color: "c-nav-fg-alt", + disabled: isLoading, + onClick: () => dispatch(adt("cancelEditing") as Msg) + } + ]); + } switch (propStatus) { case SWUProposalStatus.TeamQuestionsPanelIndividual: return component_.page.actions.links( - state.questionEvaluation - ? state.questionEvaluation.status === - SWUTeamQuestionResponseEvaluationStatus.Draft - ? [ + state.evaluating + ? state.questionEvaluation + ? state.questionEvaluation.status === + SWUTeamQuestionResponseEvaluationStatus.Draft && + state.questionEvaluation.type === + SWUTeamQuestionResponseEvaluationType.Individual + ? [ + { + children: "Edit", + onClick: () => dispatch(adt("startEditing")), + button: true, + loading: isStartEditingLoading, + disabled: isLoading, + symbol_: leftPlacement(iconLinkSymbol("edit")), + color: "primary" + } + ] + : [] + : [ { - children: "Save Changes", + children: "Save Draft", symbol_: leftPlacement(iconLinkSymbol("save")), loading: isSaveLoading, - disabled: isSaveLoading || !valid, + disabled: isLoading || !valid, button: true, color: "success", - onClick: () => dispatch(adt("saveChanges")) + onClick: () => dispatch(adt("saveDraft")) }, { children: "Cancel", color: "c-nav-fg-alt", - disabled: isSaveLoading, + disabled: isLoading, onClick: () => - dispatch(adt("showModal", adt("cancel")) as Msg) + dispatch(adt("showModal", adt("cancelDraft")) as Msg) } ] - : [] - : [ - { - children: "Save Draft", - symbol_: leftPlacement(iconLinkSymbol("save")), - loading: isSaveLoading, - disabled: isSaveLoading || !valid, - button: true, - color: "success", - onClick: () => dispatch(adt("saveDraft")) - }, - { - children: "Cancel", - color: "c-nav-fg-alt", - disabled: isSaveLoading, - onClick: () => - dispatch(adt("showModal", adt("cancelDraft")) as Msg) - } - ] + : [] ); // case SWUProposalStatus.EvaluatedTeamQuestions: // return component_.page.actions.links([ diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx index 9f9e8cf14..d5d74ef62 100644 --- a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx @@ -59,6 +59,7 @@ function makeInit(): component_.page.Init< routeParams, proposal, opportunity, + true, undefined, panelEvaluations ]) as Msg; diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx index 470781184..e9997d880 100644 --- a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx @@ -56,6 +56,7 @@ function makeInit(): component_.page.Init< routeParams, proposal, opportunity, + true, undefined, [] // No evaluations to load ]) as Msg; diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx index f25c35a74..6de61c458 100644 --- a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx @@ -63,6 +63,7 @@ function makeInit(): component_.page.Init< routeParams, proposal, opportunity, + true, evaluation, panelEvaluations ]) as Msg; diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx index fb772b43d..59674be00 100644 --- a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx @@ -60,6 +60,7 @@ function makeInit(): component_.page.Init< routeParams, proposal, opportunity, + true, evaluation, [] ]) as Msg;