diff --git a/src/back-end/lib/db/opportunity/team-with-us.ts b/src/back-end/lib/db/opportunity/team-with-us.ts index 06565ea1c..e155e03c0 100644 --- a/src/back-end/lib/db/opportunity/team-with-us.ts +++ b/src/back-end/lib/db/opportunity/team-with-us.ts @@ -3,6 +3,7 @@ import { Connection, RawTWUOpportunitySubscriber, readOneOrganizationContactEmail, + readOneServiceAreaByServiceAreaId, readOneTWUAwardedProposal, readSubmittedTWUProposalCount, Transaction, @@ -25,8 +26,9 @@ import { TWUOpportunityHistoryRecord, TWUOpportunitySlim, TWUOpportunityStatus, + TWUResource, TWUResourceQuestion, - TWUServiceArea + ValidatedCreateTWUResourceBody } from "shared/lib/resources/opportunity/team-with-us"; import { AuthenticatedSession, Session } from "shared/lib/resources/session"; import { User, UserType } from "shared/lib/resources/user"; @@ -37,6 +39,7 @@ import { TWUProposalStatus } from "shared/lib/resources/proposal/team-with-us"; import * as twuOpportunityNotifications from "back-end/lib/mailer/notifications/opportunity/team-with-us"; +import { ServiceAreaId } from "shared/lib/resources/service-area"; /** * @remarks @@ -45,6 +48,9 @@ import * as twuOpportunityNotifications from "back-end/lib/mailer/notifications/ * with Team With Us Opportunities */ +/** + * serviceArea is intentionally a number value for CreateTWUOpportunityParams, not an enum (backwards compatibility) + */ export interface CreateTWUOpportunityParams extends Omit< TWUOpportunity, @@ -56,11 +62,11 @@ export interface CreateTWUOpportunityParams | "id" | "addenda" | "resourceQuestions" - | "serviceArea" + | "resources" > { status: CreateTWUOpportunityStatus; resourceQuestions: CreateTWUResourceQuestionBody[]; - serviceArea: number; + resources: ValidatedCreateTWUResourceBody[]; } interface UpdateTWUOpportunityParams @@ -80,14 +86,17 @@ interface TWUOpportunityVersionRecord opportunity: Id; } -interface TWUResourceRecord - extends Pick< - TWUOpportunity, - "targetAllocation" | "optionalSkills" | "mandatorySkills" - > { +/** + * serviceArea is intentionally a number value here, not an enum (backwards compatibility) + */ +interface TWUResourceRecord { id: Id; + serviceArea: ServiceAreaId; opportunityVersion: Id; - serviceArea: number; + mandatorySkills: string[]; + optionalSkills: string[]; + targetAllocation: number; + order: number; } interface TWUOpportunityStatusRecord { @@ -99,25 +108,42 @@ interface TWUOpportunityStatusRecord { note: string; } +/** + * Raw is a naming convention typically used to indicate that it handles data + * after a read action from the database + * + * @example + * resources, for instance only needs to be an array of ids to feed a subsequent db query + * for resources that match the array of ids passed to it. + */ export interface RawTWUOpportunity extends Omit< TWUOpportunity, - "createdBy" | "updatedBy" | "attachments" | "addenda" | "resourceQuestions" + | "createdBy" + | "updatedBy" + | "attachments" + | "addenda" + | "resourceQuestions" + | "resources" > { createdBy?: Id; updatedBy?: Id; attachments: Id[]; addenda: Id[]; resourceQuestions: Id[]; + resources: Id[]; versionId?: Id; } +/** + * @privateRemarks + * removed serviceArea 01/01/2024 + */ export interface RawTWUOpportunitySlim extends Omit { createdBy?: Id; updatedBy?: Id; versionId: Id; - serviceArea: TWUServiceArea; } interface RawTWUOpportunityAddendum extends Omit { @@ -129,6 +155,16 @@ interface RawResourceQuestion extends Omit { opportunityVersion: Id; } +/** + * Raw is a naming convention that references data that's read from the db and has yet to be massaged into a relevant + * type or interface + * object + * @TODO - seems too slim + */ +interface RawResource { + id: Id; +} + interface RawTWUOpportunityHistoryRecord extends Omit< TWUOpportunityHistoryRecord, @@ -157,6 +193,7 @@ async function rawTWUOpportunityToTWUOpportunity( createdBy: createdById, updatedBy: updatedById, attachments: attachmentIds, + resources: resourceIds, versionId, ...restOfRaw } = raw; @@ -185,7 +222,12 @@ async function rawTWUOpportunityToTWUOpportunity( undefined ); - if (!addenda || !resourceQuestions) { + const resources = getValidValue( + await readManyResources(connection, raw.versionId ?? ""), + undefined + ); + + if (!addenda || !resourceQuestions || !resources) { throw new Error("unable to process opportunity"); } @@ -195,7 +237,8 @@ async function rawTWUOpportunityToTWUOpportunity( updatedBy: updatedBy || undefined, attachments, addenda, - resourceQuestions + resourceQuestions, + resources }; } @@ -216,7 +259,6 @@ async function rawTWUOpportunitySlimToTWUOpportunitySlim( createdBy: createdById, updatedBy: updatedById, versionId, - serviceArea, ...restOfRaw } = raw; const createdBy = @@ -294,6 +336,74 @@ async function rawResourceQuestionToResourceQuestion( }; } +/** + * Reads a TWU resource from the database, if given id of the resource + */ +export const readOneResource = tryDb<[Id], TWUResourceRecord | null>( + async (connection, id) => { + const result = await connection("twuResources") + .where({ id }) + .select( + "id", + "serviceArea", + "targetAllocation", + "opportunityVersion", + "mandatorySkills", + "optionalSkills", + "order" + ) + .first(); + return valid(result ? result : null); + } +); + +/** + * `Raw` naming convention typically indicates data that's been derived from a read action on the db, + * in this particular case, the 'raw' data is the `id` or primary key of a TWUResource obtained from + * a previous query. + * + * @param connection + * @param raw + * @returns TWUResourceRecord - the shape of the database table + */ +async function rawResourceToResource( + connection: Connection, + raw: RawResource +): Promise { + const { id } = raw; + const resource = id + ? getValidValue(await readOneResource(connection, id), undefined) + : undefined; + + if (!resource) { + throw new Error("unable to process resource"); + } + + // convert the serviceAreaId number back to an enumerated value + const serviceArea = resource.serviceArea + ? getValidValue( + await readOneServiceAreaByServiceAreaId( + connection, + resource.serviceArea + ), + undefined + ) + : undefined; + + if (!serviceArea) { + throw new Error("unable to process resource"); + } + + return { + id, + serviceArea, + targetAllocation: resource.targetAllocation, + mandatorySkills: resource.mandatorySkills, + optionalSkills: resource.optionalSkills, + order: resource.order + }; +} + /** * Safety Check. Prior to putting data in the db, receives a TWU * opportunity history record from user input, ensures that values such as @@ -328,7 +438,7 @@ async function rawHistoryRecordToHistoryRecord( } /** - * Retrieves the latest version of a TWU opportunity from the db. Will return + * Retrieves the latest versions of a TWU opportunities from the db. Will return * either a query for the full record of a TWU opp, or a query that retrieves a * slimmed down version of it * @@ -365,12 +475,6 @@ export function generateTWUOpportunityQuery( ) ); }) - .join("twuResources as tr", function () { - this.on("tr.opportunityVersion", "=", "versions.id"); - }) - .join("serviceAreas as sa", function () { - this.on("tr.serviceArea", "=", "sa.id"); - }) .select( "opportunities.id", "opportunities.createdAt", @@ -388,17 +492,13 @@ export function generateTWUOpportunityQuery( "versions.location", "versions.maxBudget", "versions.proposalDeadline", - "statuses.status", - "sa.serviceArea" + "statuses.status" ); if (full) { query.select( "versions.remoteDesc", "versions.maxBudget", - "tr.targetAllocation", - "tr.mandatorySkills", - "tr.optionalSkills", "versions.description", "versions.assignmentDate", "versions.questionsWeight", @@ -408,7 +508,6 @@ export function generateTWUOpportunityQuery( "versions.completionDate" ); } - return query; } @@ -507,10 +606,31 @@ export const readManyResourceQuestions = tryDb<[Id], TWUResourceQuestion[]>( } ); +/** + * Reads TWUResources from the database, when given opportunityVersion id that's connected to the Resources + */ +export const readManyResources = tryDb<[Id], TWUResource[]>( + async (connection, opportunityVersionId) => { + const results = await connection("twuResources") + .where("opportunityVersion", opportunityVersionId) + .orderBy("order", "asc"); + if (!results) { + throw new Error("unable to read resources"); + } + return valid( + await Promise.all( + results.map(async (raw) => await rawResourceToResource(connection, raw)) + ) + ); + } +); + export const readManyTWUOpportunities = tryDb<[Session], TWUOpportunitySlim[]>( async (connection, session) => { + // broad query returning many TWU Opportunities let query = generateTWUOpportunityQuery(connection); + // gets further refined with WHERE clauses if (!session || session.user.type === UserType.Vendor) { // Anonymous users and vendors can only see public opportunities query = query.whereIn( @@ -630,10 +750,12 @@ export const readOneTWUOpportunity = tryDb< [Id, Session], TWUOpportunity | null >(async (connection, id, session) => { + // returns one row based on opportunity id let query = generateTWUOpportunityQuery(connection, true).where({ "opportunities.id": id }); + // further refines query with where conditions if (!session || session.user.type === UserType.Vendor) { // Anonymous users and vendors can only see public opportunities query = query.whereIn( @@ -660,9 +782,9 @@ export const readOneTWUOpportunity = tryDb< ...privateOpportunityStatuses ]); } - + // 'First' is similar to select, but only retrieves & resolves with the first record from the query let result = await query.first(); - + // console.log("LINE 708 readOneTWUOpporutunity after query: ", result) if (result) { // Process based on user type result = processForRole(result, session); @@ -674,6 +796,13 @@ export const readOneTWUOpportunity = tryDb< .select("file") ).map((row) => row.file); + // Query for resources + result.resources = ( + await connection("twuResources") + .where({ opportunityVersion: result.versionId }) + .select("id") + ).map((row) => row.id); + // Get published date if applicable const conditions = { opportunity: result.id, @@ -800,7 +929,49 @@ export const readOneTWUOpportunity = tryDb< } } } - + // console.log("LINE 793 readOneTwuOpportunity after initial query: ",result) + /** + * LINE 793 readOneTwuOpportunity after initial query: { + * id: '962e8ac7-1ebf-48c3-80c7-6329ee4fd361', + * createdAt: 2024-01-18T23:48:09.782Z, + * createdBy: '5a5db155-0d29-4bb6-abe3-545ac3166dea', + * versionId: 'd79b79e8-438b-4c0b-8c2a-11d62458de9f', + * updatedAt: 2024-01-18T23:48:09.782Z, + * updatedBy: '5a5db155-0d29-4bb6-abe3-545ac3166dea', + * title: 'testing many Team questions for data structure', + * teaser: 'fdsa', + * remoteOk: false, + * location: 'Victoria', + * maxBudget: 1234, + * proposalDeadline: 2024-02-02T00:00:00.000Z, + * status: 'DRAFT', + * remoteDesc: '', + * description: 'fdsa', + * assignmentDate: 2024-02-03T00:00:00.000Z, + * questionsWeight: 25, + * challengeWeight: 50, + * priceWeight: 25, + * startDate: 2024-02-04T00:00:00.000Z, + * completionDate: 2024-02-05T00:00:00.000Z, + * attachments: [], + * resources: [ + * '33210a8f-e352-494e-94f5-763909940d05', + * '70b924dd-b1a5-4fef-9e53-5759f6728456' + * ], + * publishedAt: undefined, + * subscribed: false, + * history: [ + * { + * id: '171d0f08-f9ff-47ca-a1da-a841386cfbbe', + * createdAt: 2024-01-18T23:48:09.782Z, + * opportunity: '962e8ac7-1ebf-48c3-80c7-6329ee4fd361', + * note: null, + * createdBy: [Object], + * type: [Object] + * } + * ] + * } + */ return valid( result ? await rawTWUOpportunityToTWUOpportunity(connection, result) : null ); @@ -835,10 +1006,7 @@ export const createTWUOpportunity = tryDb< attachments, status, resourceQuestions, - targetAllocation, - mandatorySkills, - optionalSkills, - serviceArea, + resources, ...restOfOpportunity } = opportunity; const [opportunityVersionRecord] = @@ -859,24 +1027,17 @@ export const createTWUOpportunity = tryDb< throw new Error("unable to create opportunity version"); } - const [twuResourceRecord] = await connection( - "twuResources" - ) - .transacting(trx) - .insert( - { + // Create resources + for (const twuResource of opportunity.resources) { + await connection( + "twuResources" + ) + .transacting(trx) + .insert({ + ...twuResource, id: generateUuid(), - opportunityVersion: opportunityVersionRecord.id, - serviceArea, - targetAllocation, - mandatorySkills, - optionalSkills - }, - "*" - ); - - if (!twuResourceRecord) { - throw new Error("unable to create resource"); + opportunityVersion: opportunityVersionRecord.id + }); } // Create initial opportunity status @@ -939,16 +1100,31 @@ export const updateTWUOpportunityVersion = tryDb< TWUOpportunity >(async (connection, opportunity, session) => { const now = new Date(); - const { - attachments, - resourceQuestions, - targetAllocation, - mandatorySkills, - optionalSkills, - serviceArea, - ...restOfOpportunity - } = opportunity; + const { attachments, resourceQuestions, resources, ...restOfOpportunity } = + opportunity; const opportunityVersion = await connection.transaction(async (trx) => { + const prevResources: TWUResourceRecord[] = await connection< + TWUResourceRecord & { opportunityVersion: Id } + >("twuResources as tr") + .select("tr.*") + .join( + "twuOpportunityVersions as tov", + "tr.opportunityVersion", + "=", + "tov.id" + ) + .where( + "tov.createdAt", + "=", + connection("twuOpportunityVersions as tov2") + .max("createdAt") + .where("tov2.opportunity", "=", restOfOpportunity.id) + ); + + if (prevResources.length === 0) { + throw new Error("could not fetch previous resources"); + } + const [versionRecord] = await connection( "twuOpportunityVersions" ) @@ -968,24 +1144,56 @@ export const updateTWUOpportunityVersion = tryDb< throw new Error("unable to update opportunity"); } - const [twuResourceRecord] = await connection( - "twuResources" - ) - .transacting(trx) - .insert( - { - id: generateUuid(), - opportunityVersion: versionRecord.id, - serviceArea, - targetAllocation, - mandatorySkills, - optionalSkills - }, - "*" - ); + for (const twuResourceRecord of resources) { + const id = generateUuid(); + await connection( + "twuResources" + ) + .transacting(trx) + .insert( + { + ...twuResourceRecord, + id, + opportunityVersion: versionRecord.id + }, + "*" + ); + if (!twuResourceRecord) { + throw new Error("unable to update resource"); + } - if (!twuResourceRecord) { - throw new Error("unable to update resource"); + /** + * If any of the previous resources have the same properties + * update team members that referred to that resource so that + * they point to the new resource. + */ + for (const pr of prevResources) { + if ( + pr.serviceArea === twuResourceRecord.serviceArea && + pr.targetAllocation === twuResourceRecord.targetAllocation && + pr.mandatorySkills.every( + (skill, index) => skill === twuResourceRecord.mandatorySkills[index] + ) && + pr.optionalSkills.every( + (skill, index) => skill === twuResourceRecord.optionalSkills[index] + ) && + pr.order === twuResourceRecord.order + ) { + const [{ memberCount }] = await connection("twuProposalMember") + .count("member", { as: "memberCount" }) + .where("resource", "=", pr.id); + const result = await connection("twuProposalMember") + .transacting(trx) + .where("resource", "=", pr.id) + .update({ resource: id }); + + if (result !== Number(memberCount)) { + throw new Error( + "unable to port new resource to proposal team members" + ); + } + } + } } // Create attachments diff --git a/src/back-end/lib/db/proposal/team-with-us.ts b/src/back-end/lib/db/proposal/team-with-us.ts index ea8e3a6eb..b8c18f98b 100644 --- a/src/back-end/lib/db/proposal/team-with-us.ts +++ b/src/back-end/lib/db/proposal/team-with-us.ts @@ -149,6 +149,14 @@ async function rawHistoryRecordToHistoryRecord( }; } +/** + * "raw" indicates reading data from the db and modifying the data to conform + * to type declarations in the application. In this case, the db holds a memberID + * which is then used to get a username 'idpUsername'. + * + * @param connection + * @param raw + */ async function rawProposalTeamMemberToProposalTeamMember( connection: Connection, raw: RawProposalTeamMember @@ -643,7 +651,7 @@ const readTWUProposalMembers = tryDb<[Id], RawProposalTeamMember[]>( proposalId ); - query.select("member", "hourlyRate"); + query.select("member", "hourlyRate", "resource"); const results = await query; @@ -856,7 +864,8 @@ async function createTWUProposalTeamMembers( { proposal: proposalId, member: teamMember.member, - hourlyRate: teamMember.hourlyRate + hourlyRate: teamMember.hourlyRate, + resource: teamMember.resource }, "*" ); @@ -1255,6 +1264,7 @@ async function calculatePriceScore( "=", "members.proposal" ) + .join("twuResources as resources", "members.resource", "=", "resources.id") .whereIn("proposals.opportunity", function () { this.where({ id: proposalId }).select("opportunity").from("twuProposals"); }) @@ -1262,7 +1272,13 @@ async function calculatePriceScore( TWUProposalStatus.UnderReviewChallenge, TWUProposalStatus.EvaluatedChallenge ]) - .sum("members.hourlyRate as bid") + // multiple resources requires matching the hourly rate of the team member + // with the targetAllocation for the opportunity + .select( + connection.raw( + 'SUM(members."hourlyRate" * resources."targetAllocation" / 100) AS bid' + ) + ) .groupBy("proposals.id") .select("proposals.id") .orderBy("bid", "asc"); diff --git a/src/back-end/lib/db/service-area.ts b/src/back-end/lib/db/service-area.ts index 1e29cbede..4111245ae 100644 --- a/src/back-end/lib/db/service-area.ts +++ b/src/back-end/lib/db/service-area.ts @@ -5,7 +5,15 @@ import { TWUServiceAreaRecord } from "shared/lib/resources/service-area"; import { Id } from "shared/lib/types"; +import { TWUServiceArea } from "shared/lib/resources/opportunity/team-with-us"; +/** + * Reads a service area from the database, returns a number (id) if given a string value (enum) + * + * @param connection + * @param serviceArea - the enum value + * @returns ServiceAreaId - the id value of the service area in the database + */ export const readOneServiceAreaByServiceArea = tryDb< [Id], ServiceAreaId | null @@ -15,3 +23,20 @@ export const readOneServiceAreaByServiceArea = tryDb< .first(); return valid(result ? result.id : null); }); + +/** + * Reads a service area from the database, returns a service area (enum) if given a number (id) value + * + * @param connection + * @param id - serviceAreaId + * @returns TWUServiceArea - the enum value + */ +export const readOneServiceAreaByServiceAreaId = tryDb< + [ServiceAreaId], + TWUServiceArea | null +>(async (connection, id) => { + const result = await connection("serviceAreas") + .where({ id }) + .first(); + return valid(result ? result.serviceArea : null); +}); diff --git a/src/back-end/lib/mailer/notifications/affiliation.tsx b/src/back-end/lib/mailer/notifications/affiliation.tsx index c016ddd38..cd7fa9ac6 100644 --- a/src/back-end/lib/mailer/notifications/affiliation.tsx +++ b/src/back-end/lib/mailer/notifications/affiliation.tsx @@ -87,8 +87,8 @@ export async function addedToTeamT(affiliation: Affiliation): Promise {

If you approve the membership, you can be included as a team - member on future Sprint With Us proposals submitted by{" "} - {organization.legalName}. + member on future Sprint With Us or Team With Us proposals + submitted by {organization.legalName}.

), @@ -124,7 +124,8 @@ export async function approvedRequestToJoinT(

{memberName} can now be included in proposals submitted by{" "} - {organizationName} to Sprint With Us opportunities. + {organizationName} to Sprint With Us or Team With Us + opportunities.

), @@ -184,7 +185,7 @@ export async function membershipCompleteT(

{organizationName} can now include you on proposals to Sprint With - Us opportunities. + Us or Team With Us opportunities.

), @@ -214,7 +215,8 @@ export async function memberLeavesT(

{memberName} has left {organizationName} {"'"}s team on the Digital Marketplace. They will no longer be - able to be included on proposals for Sprint With Us opportunities. + able to be included on proposals for Sprint With Us or Team With + Us opportunities.

), diff --git a/src/back-end/lib/mailer/notifications/opportunity/team-with-us.tsx b/src/back-end/lib/mailer/notifications/opportunity/team-with-us.tsx index 5d20027d6..ca73c4a56 100644 --- a/src/back-end/lib/mailer/notifications/opportunity/team-with-us.tsx +++ b/src/back-end/lib/mailer/notifications/opportunity/team-with-us.tsx @@ -190,13 +190,14 @@ export function makeTWUOpportunityInformation( opportunity: TWUOpportunity, showDueDate = true ): templates.DescriptionListProps { + const serviceAreas = opportunity.resources.map((resource, index) => ({ + name: "Service Area ".concat(`${index + 1}`), + value: `${startCase(lowerCase(resource.serviceArea))}` + })); const items = [ { name: "Type", value: "Team With Us" }, { name: "Value", value: `$${formatAmount(opportunity.maxBudget)}` }, - { - name: "Service Area", - value: `${startCase(lowerCase(opportunity.serviceArea))}` - }, + ...serviceAreas, { name: "Contract Start Date", value: formatDate(opportunity.startDate, false) diff --git a/src/back-end/lib/mailer/notifications/user.tsx b/src/back-end/lib/mailer/notifications/user.tsx index 11d41bd21..271617d97 100644 --- a/src/back-end/lib/mailer/notifications/user.tsx +++ b/src/back-end/lib/mailer/notifications/user.tsx @@ -52,7 +52,7 @@ export async function inviteToRegisterT( />{" "} for a Digital Marketplace account. Once you have signed up, you can join their team and be included in proposals to Sprint With Us - opportunities. + or Team With Us opportunities.

), diff --git a/src/back-end/lib/resources/opportunity/sprint-with-us.ts b/src/back-end/lib/resources/opportunity/sprint-with-us.ts index 114ac0772..13e128521 100644 --- a/src/back-end/lib/resources/opportunity/sprint-with-us.ts +++ b/src/back-end/lib/resources/opportunity/sprint-with-us.ts @@ -488,7 +488,7 @@ const create: crud.Create< const validatedMandatorySkills = genericValidation.validateMandatorySkills(mandatorySkills); const validatedOptionalSkills = - opportunityValidation.validateOptionalSkills(optionalSkills); + genericValidation.validateOptionalSkills(optionalSkills); const validatedDescription = genericValidation.validateDescription(description); const validatedQuestionsWeight = @@ -1077,7 +1077,7 @@ const update: crud.Update< const validatedMandatorySkills = genericValidation.validateMandatorySkills(mandatorySkills); const validatedOptionalSkills = - opportunityValidation.validateOptionalSkills(optionalSkills); + genericValidation.validateOptionalSkills(optionalSkills); const validatedDescription = genericValidation.validateDescription(description); const validatedQuestionsWeight = @@ -1295,7 +1295,7 @@ const update: crud.Update< genericValidation.validateMandatorySkills( validatedSWUOpportunity.value.mandatorySkills ), - opportunityValidation.validateOptionalSkills( + genericValidation.validateOptionalSkills( validatedSWUOpportunity.value.optionalSkills ), genericValidation.validateDescription( diff --git a/src/back-end/lib/resources/opportunity/team-with-us.ts b/src/back-end/lib/resources/opportunity/team-with-us.ts index 63f5cd808..5fdb3ef40 100644 --- a/src/back-end/lib/resources/opportunity/team-with-us.ts +++ b/src/back-end/lib/resources/opportunity/team-with-us.ts @@ -11,7 +11,7 @@ import { import { validateAttachments, validateTWUOpportunityId, - validateServiceArea + validateTWUResources } from "back-end/lib/validation"; import { get, omit } from "lodash"; import { addDays, getNumber, getString, getStringArray } from "shared/lib"; @@ -21,6 +21,7 @@ import { CreateTWUOpportunityStatus, CreateTWUResourceQuestionBody, CreateTWUResourceQuestionValidationErrors, + CreateTWUResourceValidationErrors, CreateValidationErrors, DeleteValidationErrors, isValidStatusChange, @@ -28,7 +29,9 @@ import { TWUOpportunitySlim, TWUOpportunityStatus, UpdateRequestBody, - UpdateValidationErrors + UpdateValidationErrors, + CreateTWUResourceBody, + ValidatedCreateTWUResourceBody } from "shared/lib/resources/opportunity/team-with-us"; import { AuthenticatedSession, Session } from "shared/lib/resources/session"; import { @@ -46,6 +49,10 @@ import * as genericValidation from "shared/lib/validation/opportunity/utility"; import * as twuOpportunityNotifications from "back-end/lib/mailer/notifications/opportunity/team-with-us"; import { ADT, adt, Id } from "shared/lib/types"; +/** + * @remarks + * serviceArea is intentionally declared as a number here, not an enum (backwards compatibility) + */ interface ValidatedCreateRequestBody extends Omit< TWUOpportunity, @@ -59,14 +66,14 @@ interface ValidatedCreateRequestBody | "history" | "publishedAt" | "subscribed" + | "resources" | "resourceQuestions" | "challengeEndDate" - | "serviceArea" > { + resources: ValidatedCreateTWUResourceBody[]; status: CreateTWUOpportunityStatus; session: AuthenticatedSession; resourceQuestions: CreateTWUResourceQuestionBody[]; - serviceArea: number; } interface ValidatedUpdateRequestBody { @@ -81,17 +88,22 @@ interface ValidatedUpdateRequestBody { | ADT<"addAddendum", string>; } +/** + * @remarks + * serviceArea is intentionally a number here (via TWUResource[]), not an enum (backwards compatibility) + * @see ValidatedCreateRequestBody + */ type ValidatedUpdateEditRequestBody = Omit< ValidatedCreateRequestBody, - "status" | "session" | "serviceArea" -> & { serviceArea: number }; + "status" | "session" +>; type CreateRequestBody = Omit< SharedCreateRequestBody, - "status" | "serviceArea" + "status" | "resources" > & { + resources: CreateTWUResourceBody[]; status: string; - serviceArea: string; }; type ValidatedDeleteRequestBody = Id; @@ -176,11 +188,12 @@ const readOne: crud.ReadOne = ( const create: crud.Create< Session, db.Connection, - CreateRequestBody, - ValidatedCreateRequestBody, - CreateValidationErrors + CreateRequestBody, // serviceArea = enum + ValidatedCreateRequestBody, // serviceArea = number + CreateValidationErrors // serviceArea = enum > = (connection: db.Connection) => { return { + // obtain values from each part of the incoming request body async parseRequestBody(request) { const body: unknown = request.body.tag === "json" ? request.body.value : {}; @@ -190,10 +203,7 @@ const create: crud.Create< remoteOk: get(body, "remoteOk"), remoteDesc: getString(body, "remoteDesc"), location: getString(body, "location"), - mandatorySkills: getStringArray(body, "mandatorySkills"), - optionalSkills: getStringArray(body, "optionalSkills"), - serviceArea: getString(body, "serviceArea"), - targetAllocation: getNumber(body, "targetAllocation"), + resources: get(body, "resources"), description: getString(body, "description"), proposalDeadline: getString(body, "proposalDeadline"), assignmentDate: getString(body, "assignmentDate"), @@ -208,6 +218,7 @@ const create: crud.Create< resourceQuestions: get(body, "resourceQuestions") }; }, + // ensure the accuracy of values coming in from the request body async validateRequestBody(request) { const { title, @@ -215,10 +226,7 @@ const create: crud.Create< remoteOk, remoteDesc, location, - mandatorySkills, - optionalSkills, - serviceArea, - targetAllocation, + resources, description, proposalDeadline, assignmentDate, @@ -265,14 +273,13 @@ const create: crud.Create< }); } - // Service areas are required for drafts - const validatedServiceArea = await validateServiceArea( + const validatedResources = await validateTWUResources( connection, - serviceArea + resources ); - if (isInvalid(validatedServiceArea)) { + if (isInvalid(validatedResources)) { return invalid({ - serviceArea: validatedServiceArea.value + resources: validatedResources.value }); } @@ -295,7 +302,7 @@ const create: crud.Create< getValidValue(validatedStartDate, now) ); - // Do not validate other fields if the opportunity a draft + // Validate the following fields if the opportunity is saved as a draft if (validatedStatus.value === TWUOpportunityStatus.Draft) { const defaultDate = addDays(new Date(), 14); return valid({ @@ -320,10 +327,9 @@ const create: crud.Create< assignmentDate: getValidValue(validatedAssignmentDate, defaultDate), startDate: getValidValue(validatedStartDate, defaultDate), completionDate: getValidValue(validatedCompletionDate, defaultDate), - serviceArea: validatedServiceArea.value + resources: validatedResources.value }); } - const validatedTitle = genericValidation.validateTitle(title); const validatedTeaser = genericValidation.validateTeaser(teaser); const validatedRemoteOk = genericValidation.validateRemoteOk(remoteOk); @@ -334,12 +340,6 @@ const create: crud.Create< const validatedLocation = genericValidation.validateLocation(location); const validatedMaxBudget = opportunityValidation.validateMaxBudget(maxBudget); - const validatedMandatorySkills = - genericValidation.validateMandatorySkills(mandatorySkills); - const validatedOptionalSkills = - opportunityValidation.validateOptionalSkills(optionalSkills); - const validatedTargetAllocation = - opportunityValidation.validateTargetAllocation(targetAllocation); const validatedDescription = genericValidation.validateDescription(description); const validatedQuestionsWeight = @@ -350,7 +350,6 @@ const create: crud.Create< opportunityValidation.validatePriceWeight(priceWeight); const validatedResourceQuestions = opportunityValidation.validateResourceQuestions(resourceQuestions); - if ( allValid([ validatedTitle, @@ -359,10 +358,7 @@ const create: crud.Create< validatedRemoteDesc, validatedLocation, validatedMaxBudget, - validatedMandatorySkills, - validatedOptionalSkills, - validatedServiceArea, - validatedTargetAllocation, + validatedResources, validatedDescription, validatedQuestionsWeight, validatedChallengeWeight, @@ -373,8 +369,7 @@ const create: crud.Create< validatedStartDate, validatedCompletionDate, validatedAttachments, - validatedStatus, - validatedServiceArea + validatedStatus ]) ) { // Ensure that score weights total 100% @@ -397,10 +392,7 @@ const create: crud.Create< remoteDesc: validatedRemoteDesc.value, location: validatedLocation.value, maxBudget: validatedMaxBudget.value, - mandatorySkills: validatedMandatorySkills.value, - optionalSkills: validatedOptionalSkills.value, - serviceArea: validatedServiceArea.value, - targetAllocation: validatedTargetAllocation.value, + resources: validatedResources.value, description: validatedDescription.value, questionsWeight: validatedQuestionsWeight.value, challengeWeight: validatedChallengeWeight.value, @@ -421,18 +413,10 @@ const create: crud.Create< remoteDesc: getInvalidValue(validatedRemoteDesc, undefined), location: getInvalidValue(validatedLocation, undefined), maxBudget: getInvalidValue(validatedMaxBudget, undefined), - mandatorySkills: getInvalidValue( - validatedMandatorySkills, - undefined - ), - optionalSkills: getInvalidValue( - validatedOptionalSkills, + resources: getInvalidValue< + CreateTWUResourceValidationErrors[], undefined - ), - targetAllocation: getInvalidValue( - validatedTargetAllocation, - undefined - ), + >(validatedResources, undefined), description: getInvalidValue(validatedDescription, undefined), questionsWeight: getInvalidValue(validatedQuestionsWeight, undefined), challengeWeight: getInvalidValue(validatedChallengeWeight, undefined), @@ -506,9 +490,9 @@ const create: crud.Create< const update: crud.Update< Session, db.Connection, - UpdateRequestBody, - ValidatedUpdateRequestBody, - UpdateValidationErrors + UpdateRequestBody, // serviceArea = enum + ValidatedUpdateRequestBody, // serviceArea = number + UpdateValidationErrors // serviceArea = enum > = (connection: db.Connection) => { return { async parseRequestBody(request) { @@ -523,10 +507,7 @@ const update: crud.Update< remoteOk: get(value, "remoteOk"), remoteDesc: getString(value, "remoteDesc"), location: getString(value, "location"), - mandatorySkills: getStringArray(value, "mandatorySkills"), - optionalSkills: getStringArray(value, "optionalSkills"), - serviceArea: getString(value, "serviceArea"), - targetAllocation: getNumber(value, "targetAllocation"), + resources: get(value, "resources"), description: getString(value, "description"), proposalDeadline: getString(value, "proposalDeadline"), assignmentDate: getString(value, "assignmentDate"), @@ -569,6 +550,7 @@ const update: crud.Update< notFound: ["The specified opportunity does not exist."] }); } + const twuOpportunity = validatedTWUOpportunity.value; if ( @@ -592,10 +574,7 @@ const update: crud.Update< remoteOk, remoteDesc, location, - mandatorySkills, - optionalSkills, - serviceArea, - targetAllocation, + resources, description, proposalDeadline, assignmentDate, @@ -635,15 +614,16 @@ const update: crud.Update< }); } - // Service areas are required for drafts - const validatedServiceArea = await validateServiceArea( + const validatedResources = await validateTWUResources( connection, - serviceArea + resources ); - if (isInvalid(validatedServiceArea)) { + if ( + isInvalid(validatedResources) + ) { return invalid({ opportunity: adt("edit" as const, { - serviceArea: validatedServiceArea.value + resources: validatedResources.value }) }); } @@ -676,7 +656,7 @@ const update: crud.Update< getValidValue(validatedStartDate, now) ); - // Do not validate other fields if the opportunity is a draft. + // Only the following fields need validation if the opportunity is a draft. if (twuOpportunity.status === TWUOpportunityStatus.Draft) { const defaultDate = addDays(new Date(), 14); return valid({ @@ -698,7 +678,7 @@ const update: crud.Update< validatedCompletionDate, defaultDate ), - serviceArea: validatedServiceArea.value + resources: validatedResources.value }) }); } @@ -715,12 +695,6 @@ const update: crud.Update< genericValidation.validateLocation(location); const validatedMaxBudget = opportunityValidation.validateMaxBudget(maxBudget); - const validatedMandatorySkills = - genericValidation.validateMandatorySkills(mandatorySkills); - const validatedOptionalSkills = - opportunityValidation.validateOptionalSkills(optionalSkills); - const validatedTargetAllocation = - opportunityValidation.validateTargetAllocation(targetAllocation); const validatedDescription = genericValidation.validateDescription(description); const validatedQuestionsWeight = @@ -740,10 +714,7 @@ const update: crud.Update< validatedRemoteDesc, validatedLocation, validatedMaxBudget, - validatedMandatorySkills, - validatedOptionalSkills, - validatedServiceArea, - validatedTargetAllocation, + validatedResources, validatedDescription, validatedQuestionsWeight, validatedChallengeWeight, @@ -765,10 +736,7 @@ const update: crud.Update< remoteDesc: validatedRemoteDesc.value, location: validatedLocation.value, maxBudget: validatedMaxBudget.value, - mandatorySkills: validatedMandatorySkills.value, - optionalSkills: validatedOptionalSkills.value, - serviceArea: validatedServiceArea.value, - targetAllocation: validatedTargetAllocation.value, + resources: validatedResources.value, description: validatedDescription.value, questionsWeight: validatedQuestionsWeight.value, challengeWeight: validatedChallengeWeight.value, @@ -790,18 +758,10 @@ const update: crud.Update< remoteDesc: getInvalidValue(validatedRemoteDesc, undefined), location: getInvalidValue(validatedLocation, undefined), maxBudget: getInvalidValue(validatedMaxBudget, undefined), - mandatorySkills: getInvalidValue( - validatedMandatorySkills, + resources: getInvalidValue< + CreateTWUResourceValidationErrors[], undefined - ), - optionalSkills: getInvalidValue( - validatedOptionalSkills, - undefined - ), - targetAllocation: getInvalidValue( - validatedTargetAllocation, - undefined - ), + >(validatedResources, undefined), description: getInvalidValue(validatedDescription, undefined), questionsWeight: getInvalidValue( validatedQuestionsWeight, @@ -842,7 +802,8 @@ const update: crud.Update< ) { return invalid({ permissions: [permissions.ERROR_MESSAGE] }); } - // Perform validation on draft to ensure it's ready for publishing + + // Perform validation to ensure it's ready for publishing if ( !allValid([ genericValidation.validateTitle(twuOpportunity.title), @@ -854,15 +815,6 @@ const update: crud.Update< ), genericValidation.validateLocation(twuOpportunity.location), opportunityValidation.validateMaxBudget(twuOpportunity.maxBudget), - genericValidation.validateMandatorySkills( - twuOpportunity.mandatorySkills - ), - opportunityValidation.validateOptionalSkills( - twuOpportunity.optionalSkills - ), - opportunityValidation.validateTargetAllocation( - twuOpportunity.targetAllocation - ), genericValidation.validateDescription(twuOpportunity.description), opportunityValidation.validateQuestionsWeight( twuOpportunity.questionsWeight diff --git a/src/back-end/lib/resources/proposal/team-with-us.ts b/src/back-end/lib/resources/proposal/team-with-us.ts index 07eeffa2a..20cef3e86 100644 --- a/src/back-end/lib/resources/proposal/team-with-us.ts +++ b/src/back-end/lib/resources/proposal/team-with-us.ts @@ -86,9 +86,9 @@ interface ValidatedDeleteRequestBody { } /** * @typeParam CreateRequestBody - All the information that comes in the request - * body when a vendor is creating a Team with Us Proposal. SharedCreateRequestBody - * is an alias defined in this file for CreateRequestBody defined in the 'shared' - * folder. It is renamed 'CreateRequestBody' here, though redefines 'status' as a + * body when a vendor is creating a Team with Us Proposal. + * @typeParam SharedCreateRequestBody - is an alias defined in this file for CreateRequestBody + * defined in the 'shared' folder. It is renamed 'CreateRequestBody' here, though redefines 'status' as a * string instead of an enum of statuses. */ type CreateRequestBody = Omit & { @@ -105,13 +105,16 @@ const routeNamespace = "proposals/team-with-us"; * @remarks * * validates that the TWU opp id exists in the database, checks permissions of - * the user, if the request comes with the following parameters set: - * - request.query.opportunity= = (an opportunity number) it will + * the user, if the request comes with the following parameters set. + * + * @example + * + * - request.query.opportunity= = (an opportunity number) it will * return all proposals associated with that opportunity - * - request.query.organizationProposals= = it will return a response + * - request.query.organizationProposals= = it will return a response * for all proposals associated with the organizations the requester has * access to. - * - default behavior is to return the requester\'s own proposals + * - default behavior is to return the requester\'s own proposals * * @param connection */ @@ -264,7 +267,8 @@ const create: crud.Create< resourceQuestionResponses: get(body, "resourceQuestionResponses"), team: (Array.isArray(team) ? team : []).map((member) => ({ member: getString(member, "member"), - hourlyRate: getNumber(member, "hourlyRate") + hourlyRate: getNumber(member, "hourlyRate"), + resource: getString(member, "resource") })) }; }, @@ -402,7 +406,8 @@ const create: crud.Create< attachments: validatedAttachments.value, team: team.map((t) => ({ member: getString(t, "member"), - hourlyRate: getNumber(t, "hourlyRate") + hourlyRate: getNumber(t, "hourlyRate"), + resource: getString(t, "resource") })) }); } @@ -714,7 +719,8 @@ const update: crud.Update< team: team ? team.map((t) => ({ member: getString(t, "member"), - hourlyRate: getNumber(t, "hourlyRate") + hourlyRate: getNumber(t, "hourlyRate"), + resource: getString(t, "resource") })) : [] }) @@ -827,9 +833,10 @@ const update: crud.Update< await validateTWUProposalTeamMembers( connection, validatedTWUProposal.value.team?.map( - ({ member, hourlyRate }) => ({ + ({ member, hourlyRate, resource }) => ({ member: member.id, - hourlyRate + hourlyRate, + resource }) ) ?? [], validatedOrganization.value.id diff --git a/src/back-end/lib/validation.ts b/src/back-end/lib/validation.ts index d10bc3390..358e5bd46 100644 --- a/src/back-end/lib/validation.ts +++ b/src/back-end/lib/validation.ts @@ -1,7 +1,7 @@ -import { Content } from "back-end/../shared/lib/resources/content"; +import { Content } from "shared/lib/resources/content"; import * as db from "back-end/lib/db"; import { get, union } from "lodash"; -import { getNumber, getString } from "shared/lib"; +import { getNumber, getString, getStringArray } from "shared/lib"; import { Affiliation, MembershipStatus @@ -51,8 +51,11 @@ import { validateSWUProposalTeamMemberScrumMaster } from "shared/lib/validation/proposal/sprint-with-us"; import { + CreateTWUResourceBody, + CreateTWUResourceValidationErrors, parseTWUServiceArea, - TWUOpportunity + TWUOpportunity, + ValidatedCreateTWUResourceBody } from "shared/lib/resources/opportunity/team-with-us"; import { CreateTWUProposalTeamMemberBody, @@ -61,6 +64,14 @@ import { } from "shared/lib/resources/proposal/team-with-us"; import { validateTWUHourlyRate } from "shared/lib/validation/proposal/team-with-us"; import { ServiceAreaId } from "shared/lib/resources/service-area"; +import { + validateTargetAllocation, + validateOrder +} from "shared/lib/validation/opportunity/team-with-us"; +import { + validateMandatorySkills, + validateOptionalSkills +} from "shared/lib/validation/opportunity/utility"; /** * TWU - Team With Us Validation @@ -139,11 +150,24 @@ export async function validateTWUOpportunityId( } /** - * Takes a string from a validates it is a valid Team With Us service area in the database. + * Takes one string and proves that it as a valid TWU service area in the db. * * @param raw - string argument * @param connection - Knex connection wrapper - * @returns + * @returns Validation - valid serviceAreaId (key value) | invalid string (error messages) + * + * @example + * raw = "FULL_STACK_DEVELOPER" + * returns + * { + * tag: "valid", + * value: 1 + * } + * or + * { + * tag: "invalid", + * value: ["The specified service area was not found"] + * } */ export async function validateServiceArea( connection: db.Connection, @@ -173,11 +197,31 @@ export async function validateServiceArea( } /** - * Validates a list of service areas in the db. + * Takes a list of service areas and proves that each of them exists in the db. * * @param raw - string array argument * @param connection - Knex connection wrapper - * @returns + * @returns ArrayValidation - valid [array of integers] reflecting key values of serviceArea | invalid [array of + * strings] reflecting error messages + * + * @example + * raw = ["FULL_STACK_DEVELOPER", "DATA_PROFESSIONAL"] + * returns + * { + * tag: "valid", + * value: [1,2] + * } + * or + * raw = ["FULL_STACK_DEVELOPER", "DATA_PROFESSIONAL, "NOT_A_SERVICE_AREA"] + *{ + * tag: "invalid", + * value: + * [ + * [], + * [], + * ['"NOT_A_SERVICE_AREA" is not a valid service area.' ] + * ] + * } */ export function validateServiceAreas( connection: db.Connection, @@ -186,12 +230,113 @@ export function validateServiceAreas( return validateArrayAsync(raw, (v) => validateServiceArea(connection, v)); } +/** + * Takes a resource and validates it. + * + * @param connection + * @param raw + * @returns Validation - valid ValidatedCreateTWUResourceBody | invalid CreateTWUResourceValidationErrors + */ +async function validateTWUResource( + connection: db.Connection, + raw: CreateTWUResourceBody +): Promise< + Validation +> { + const validatedServiceArea = await validateServiceArea( + connection, + getString(raw, "serviceArea") + ); + const validatedTargetAllocation = validateTargetAllocation( + getNumber(raw, "targetAllocation") + ); + const validatedOrder = validateOrder(getNumber(raw, "order")); + + const validatedMandatorySkills = validateMandatorySkills( + getStringArray(raw, "mandatorySkills") + ); + const validatedOptionalSkills = validateOptionalSkills( + getStringArray(raw, "optionalSkills") + ); + if ( + allValid([ + validatedServiceArea, + validatedTargetAllocation, + validatedMandatorySkills, + validatedOptionalSkills, + validatedOrder + ]) + ) { + return valid({ + serviceArea: validatedServiceArea.value, + targetAllocation: validatedTargetAllocation.value, + mandatorySkills: validatedMandatorySkills.value, + optionalSkills: validatedOptionalSkills.value, + order: validatedOrder.value + } as ValidatedCreateTWUResourceBody); + } else { + return invalid({ + serviceArea: getInvalidValue(validatedServiceArea, undefined), + targetAllocation: getInvalidValue(validatedTargetAllocation, undefined), + mandatorySkills: getInvalidValue( + validatedMandatorySkills, + undefined + ), + optionalSkills: getInvalidValue( + validatedOptionalSkills, + undefined + ), + order: getInvalidValue(validatedOrder, undefined) + } as CreateTWUResourceValidationErrors); + } +} + +/** + * Takes a list of resources and validates each one. + * + * @param connection + * @param raw + * @returns ArrayValidation - valid [array of ValidatedCreateTWUResourceBody] | invalid [array of + * CreateTWUResourceValidationErrors] + */ +export async function validateTWUResources( + connection: db.Connection, + raw: CreateTWUResourceBody[] +): Promise< + ArrayValidation< + ValidatedCreateTWUResourceBody, + CreateTWUResourceValidationErrors + > +> { + if (!Array.isArray(raw)) { + return invalid([ + { parseFailure: ["Please provide an array of resources"] } + ]); + } + + return await validateArrayCustomAsync( + raw, + (v) => validateTWUResource(connection, v), + {} + ); +} + +/** + * Helper function to determine if an array has duplicate values + * + * @param arr + * @returns boolean - true if there are duplicate values, false otherwise. + */ +function hasDuplicates(arr: string[]): boolean { + return new Set(arr).size < arr.length; +} + /** * Checks to see if a TWU proposal's members are affiliated with the * organization in the proposal * * @param connection - database connection - * @param raw - a 'team' object, with 'member' and 'hourlyRate' elements + * @param raw - a 'team' object, with 'member', 'hourlyRate' and 'resource' elements * @param organization - organization id */ export async function validateTWUProposalTeamMembers( @@ -207,7 +352,10 @@ export async function validateTWUProposalTeamMembers( if (!raw.length) { return invalid([{ members: ["Please select at least one team member."] }]); } - + // ensure that all member values are unique + if (hasDuplicates(raw.map((v) => getString(v, "member")))) { + return invalid([{ members: ["Please select unique team members."] }]); + } return await validateArrayCustomAsync( raw, async (rawMember) => { @@ -219,10 +367,19 @@ export async function validateTWUProposalTeamMembers( const validatedHourlyRate = validateTWUHourlyRate( getNumber(rawMember, "hourlyRate") ); - if (isValid(validatedMember) && isValid(validatedHourlyRate)) { + const validatedResource = getValidValue( + await db.readOneResource(connection, getString(rawMember, "resource")), + null + ); + if ( + isValid(validatedMember) && + isValid(validatedHourlyRate) && + validatedResource + ) { return valid({ member: validatedMember.value.id, - hourlyRate: validatedHourlyRate.value + hourlyRate: validatedHourlyRate.value, + resource: validatedResource.id }); } else { return invalid({ @@ -230,7 +387,8 @@ export async function validateTWUProposalTeamMembers( validatedMember, undefined ), - hourlyRate: getInvalidValue(validatedHourlyRate, undefined) + hourlyRate: getInvalidValue(validatedHourlyRate, undefined), + resource: ["This resource cannot be found."] }) as Validation< CreateTWUProposalTeamMemberBody, CreateTWUProposalTeamMemberValidationErrors diff --git a/src/front-end/sass/index.scss b/src/front-end/sass/index.scss index ba7c211ff..c48152a96 100644 --- a/src/front-end/sass/index.scss +++ b/src/front-end/sass/index.scss @@ -149,6 +149,7 @@ $c-opportunity-view-got-questions-bg: $blue-light-alt; $c-opportunity-view-apply-bg: $blue-light-alt; $c-proposal-swu-form-team-question-response-heading: $blue-dark; $c-proposal-swu-form-scrum-master: $purple; +$c-proposal-twu-form-team-member-heading: $blue-light-alt-2; $c-user-profile-permission: $purple; // Misc. Views $c-report-card-bg: $blue-light-alt; @@ -211,6 +212,8 @@ $app-utility-colors: ( "c-proposal-swu-form-team-question-response-heading": $c-proposal-swu-form-team-question-response-heading, "c-proposal-swu-form-scrum-master": $c-proposal-swu-form-scrum-master, + "c-proposal-twu-form-team-member-heading": + $c-proposal-twu-form-team-member-heading, "c-user-profile-permission": $c-user-profile-permission, // Misc. Views "c-report-card-bg": $c-report-card-bg, diff --git a/src/front-end/typescript/lib/components/form-field/lib/select.tsx b/src/front-end/typescript/lib/components/form-field/lib/select.tsx index 0143cc77d..cae217d7e 100644 --- a/src/front-end/typescript/lib/components/form-field/lib/select.tsx +++ b/src/front-end/typescript/lib/components/form-field/lib/select.tsx @@ -45,13 +45,14 @@ export function stringsToOptions(values: string[]): ADT<"options", Option[]> { * @returns adt - options for a select list */ export function objectToOptions( - values: Record + values: Record, + formatter = startCase ): ADT<"options", Option[]> { return adt( "options", Object.entries(values).map(([key, value]) => ({ value, - label: startCase(key) + label: formatter(key) })) ); } @@ -246,7 +247,7 @@ export const view: component.base.View = (props) => { } } } as SelectProps; - const selectProps = (() => { + const { id, ...selectProps } = (() => { if (props.multi) { return { ...baseProps, @@ -280,9 +281,9 @@ export const view: component.base.View = (props) => { } })(); if (props.creatable) { - return ; + return ; } else { - return ; } }; diff --git a/src/front-end/typescript/lib/components/form-field/select.tsx b/src/front-end/typescript/lib/components/form-field/select.tsx index 6b29ed9fd..19f82fe1d 100644 --- a/src/front-end/typescript/lib/components/form-field/select.tsx +++ b/src/front-end/typescript/lib/components/form-field/select.tsx @@ -156,3 +156,10 @@ export function setValueFromString( export function getValue(state: Immutable): string { return state.child.value ? state.child.value.value : ""; } + +export function setOptions( + state: Immutable, + value: Options +): Immutable { + return state.update("child", (child) => child.set("options", value)); +} diff --git a/src/front-end/typescript/lib/http/api/opportunity/team-with-us.ts b/src/front-end/typescript/lib/http/api/opportunity/team-with-us.ts index efec7bc2b..920e5739c 100644 --- a/src/front-end/typescript/lib/http/api/opportunity/team-with-us.ts +++ b/src/front-end/typescript/lib/http/api/opportunity/team-with-us.ts @@ -149,6 +149,7 @@ function rawTWUOpportunityToTWUOpportunity( .sort((a, b) => compareDates(a.createdAt, b.createdAt) * -1), resourceQuestions: raw.resourceQuestions .map((tq) => rawTWUResourceQuestionToTWUResourceQuestion(tq)) - .sort((a, b) => compareNumbers(a.order, b.order)) + .sort((a, b) => compareNumbers(a.order, b.order)), + resources: raw.resources.sort((a, b) => compareNumbers(a.order, b.order)) }; } diff --git a/src/front-end/typescript/lib/pages/learn-more/team-with-us.tsx b/src/front-end/typescript/lib/pages/learn-more/team-with-us.tsx index 4cbcd071a..42e58a508 100644 --- a/src/front-end/typescript/lib/pages/learn-more/team-with-us.tsx +++ b/src/front-end/typescript/lib/pages/learn-more/team-with-us.tsx @@ -17,6 +17,7 @@ import { CONTACT_EMAIL, COPY, VENDOR_IDP_NAME } from "shared/config"; import ALL_SERVICE_AREAS from "shared/lib/data/service-areas"; import { ADT, adt } from "shared/lib/types"; import { GUIDE_AUDIENCE } from "front-end/lib/pages/guide/view"; +import { twuServiceAreaToTitleCase } from "../opportunity/team-with-us/lib"; export interface State { isVendorAccordionOpen: boolean; @@ -185,8 +186,10 @@ const VendorHIW: component_.base.View = () => { following service areas:

    - {ALL_SERVICE_AREAS.map(({ name }) => ( -
  • {name}
  • + {ALL_SERVICE_AREAS.map((serviceArea) => ( +
  • + {twuServiceAreaToTitleCase(serviceArea)} +
  • ))}

diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/lib/components/form.tsx b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/lib/components/form.tsx index 1631545d9..5ec284a7c 100644 --- a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/lib/components/form.tsx +++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/lib/components/form.tsx @@ -359,7 +359,7 @@ export const init: component_.base.Init = ({ errors: [], validate: (v) => { const strings = v.map(({ value }) => value); - const validated0 = opportunityValidation.validateOptionalSkills(strings); + const validated0 = genericValidation.validateOptionalSkills(strings); const validated1 = mapValid(validated0 as Validation, () => v); return mapInvalid(validated1, (es) => flatten(es)); }, diff --git a/src/front-end/typescript/lib/pages/opportunity/team-with-us/edit/index.tsx b/src/front-end/typescript/lib/pages/opportunity/team-with-us/edit/index.tsx index 953f3ac92..c64b26863 100644 --- a/src/front-end/typescript/lib/pages/opportunity/team-with-us/edit/index.tsx +++ b/src/front-end/typescript/lib/pages/opportunity/team-with-us/edit/index.tsx @@ -82,7 +82,7 @@ function makeInit(): component_.page.Init< valid( immutable({ opportunity: null, - tab: [tabId, immutable(tabState)], + tab: [tabId, immutable(tabState as Tab.Tabs[K]["state"])], sidebar: sidebarState }) ) as State_, diff --git a/src/front-end/typescript/lib/pages/opportunity/team-with-us/edit/tab/summary.tsx b/src/front-end/typescript/lib/pages/opportunity/team-with-us/edit/tab/summary.tsx index c695fccf3..cc044463c 100644 --- a/src/front-end/typescript/lib/pages/opportunity/team-with-us/edit/tab/summary.tsx +++ b/src/front-end/typescript/lib/pages/opportunity/team-with-us/edit/tab/summary.tsx @@ -1,6 +1,10 @@ import { EMPTY_STRING } from "front-end/config"; import { Route } from "front-end/lib/app/types"; -import { component as component_ } from "front-end/lib/framework"; +import { + component as component_, + Immutable, + immutable +} from "front-end/lib/framework"; import * as Tab from "front-end/lib/pages/opportunity/team-with-us/edit/tab"; import EditTabHeader from "front-end/lib/pages/opportunity/team-with-us/lib/views/edit-tab-header"; import DescriptionList from "front-end/lib/views/description-list"; @@ -16,24 +20,34 @@ import { TWUOpportunity } from "shared/lib/resources/opportunity/team-with-us"; import { NUM_SCORE_DECIMALS } from "shared/lib/resources/proposal/team-with-us"; import { isAdmin } from "shared/lib/resources/user"; import { adt, ADT } from "shared/lib/types"; -import { twuServiceAreaToTitleCase } from "front-end/lib/pages/opportunity/team-with-us/lib"; -import { map } from "lodash"; +import { lowerCase, map, startCase } from "lodash"; +import { aggregateResourceSkills } from "front-end/lib/pages/opportunity/team-with-us/lib"; +import Icon, { AvailableIcons } from "front-end/lib/views/icon"; +import * as Table from "front-end/lib/components/table"; export interface State extends Tab.Params { opportunity: TWUOpportunity | null; + table: Immutable; } -export type InnerMsg = ADT<"onInitResponse", Tab.InitResponse> | ADT<"noop">; +export type InnerMsg = + | ADT<"onInitResponse", Tab.InitResponse> + | ADT<"noop"> + | ADT<"table", Table.Msg>; export type Msg = component_.page.Msg; const init: component_.base.Init = (params) => { + const [tableState, tableCmds] = Table.init({ + idNamespace: "resources-summary-table" + }); return [ { ...params, - opportunity: null + opportunity: null, + table: immutable(tableState) }, - [] + [...component_.cmd.mapMany(tableCmds, (msg) => adt("table", msg) as Msg)] ]; }; @@ -49,6 +63,14 @@ const update: component_.page.Update = ({ [component_.cmd.dispatch(component_.page.readyMsg())] ]; } + case "table": + return component_.base.updateChild({ + state, + childStatePath: ["table", "state"], + childUpdate: Table.update, + childMsg: msg.value, + mapChildMsg: (value) => ({ tag: "table", value }) + }); default: return [state, []]; } @@ -153,20 +175,21 @@ const SuccessfulProponent: component_.page.View = ({ ); }; -const Details: component_.page.View = ({ state }) => { +const Details: component_.page.View = ({ + state, + dispatch +}) => { const opportunity = state.opportunity; if (!opportunity) return null; const { - mandatorySkills, - optionalSkills, assignmentDate, proposalDeadline, startDate, completionDate, - serviceArea, - targetAllocation, maxBudget, - location + location, + remoteOk, + resources } = opportunity; const items = [ { @@ -201,21 +224,28 @@ const Details: component_.page.View = ({ state }) => { }, { icon: "laptop-code-outline", - name: "Service Area", - value: twuServiceAreaToTitleCase(serviceArea) + name: "Remote OK", + value: remoteOk ? "Yes" : "No" }, { - icon: "balance-scale", - name: "Resource Target Allocation", - value: targetAllocation.toString().concat("%") + icon: "calendar", + name: "Contract Award Date", + value: formatDate(assignmentDate) + }, + { + icon: "calendar", + name: "Contract Start Date", + value: formatDate(startDate) } ], (rc: ReportCard): ReportCard => ({ ...rc, - className: "flex-grow-1 mr-4 mb-4" + className: "flex-grow-1 mr-3 mb-4" }) ); + const skills = aggregateResourceSkills(opportunity); + return (

@@ -228,25 +258,93 @@ const Details: component_.page.View = ({ state }) => { - - - + + + + - +
Mandatory Skills
- +
Optional Skills
- + + +
+ + +
); }; +const ServiceAreasHeading: component_.base.View<{ + icon: AvailableIcons; + text: string; +}> = ({ icon, text }) => { + return ( +
+ +

{text}

+
+ ); +}; + +function resourceTableHeadCells(): Table.HeadCells { + return [ + { + children: "Resource", + className: "text-nowrap", + style: { width: "100%" } + }, + { + children: "Allocation %", + className: "text-nowrap text-center", + style: { width: "0px" } + } + ]; +} + +function resourceTableBodyRows(state: Immutable): Table.BodyRows { + return ( + state.opportunity?.resources.map(({ serviceArea, targetAllocation }) => [ + { children: startCase(lowerCase(serviceArea)) }, + { children: targetAllocation.toString(), className: "text-center" } + ]) ?? [] + ); +} + +const ResourcesTable: component_.base.ComponentView = ({ + state, + dispatch +}) => { + return ( + + adt("table" as const, msg) + )} + hover={false} + /> + ); +}; + const view: component_.page.View = (props) => { const opportunity = props.state.opportunity; if (!opportunity) return null; diff --git a/src/front-end/typescript/lib/pages/opportunity/team-with-us/lib/components/form.tsx b/src/front-end/typescript/lib/pages/opportunity/team-with-us/lib/components/form.tsx index 6b0775580..3f368ef07 100644 --- a/src/front-end/typescript/lib/pages/opportunity/team-with-us/lib/components/form.tsx +++ b/src/front-end/typescript/lib/pages/opportunity/team-with-us/lib/components/form.tsx @@ -6,7 +6,6 @@ import * as LongText from "front-end/lib/components/form-field/long-text"; import * as NumberField from "front-end/lib/components/form-field/number"; import * as RadioGroup from "front-end/lib/components/form-field/radio-group"; import * as RichMarkdownEditor from "front-end/lib/components/form-field/rich-markdown-editor"; -import * as SelectMulti from "front-end/lib/components/form-field/select-multi"; import * as Select from "front-end/lib/components/form-field/select"; import * as ShortText from "front-end/lib/components/form-field/short-text"; import * as TabbedForm from "front-end/lib/components/tabbed-form"; @@ -17,11 +16,10 @@ import { } from "front-end/lib/framework"; import * as api from "front-end/lib/http/api"; import * as ResourceQuestions from "front-end/lib/pages/opportunity/team-with-us/lib/components/resource-questions"; -import { flatten } from "lodash"; +import * as Resources from "front-end/lib/pages/opportunity/team-with-us/lib/components/resources"; import React from "react"; import { Col, Row } from "reactstrap"; -import { arrayFromRange, getNumber } from "shared/lib"; -import SKILLS from "shared/lib/data/skills"; +import { getNumber } from "shared/lib"; import { FileUploadMetadata } from "shared/lib/resources/file"; import { canTWUOpportunityDetailsBeEdited, @@ -34,22 +32,19 @@ import { DEFAULT_QUESTIONS_WEIGHT, TWUOpportunity, TWUOpportunityStatus, - TWUServiceArea, UpdateEditValidationErrors, - parseTWUServiceArea + CreateTWUResourceBody } from "shared/lib/resources/opportunity/team-with-us"; import { isAdmin, User } from "shared/lib/resources/user"; import { adt, ADT, Id } from "shared/lib/types"; import { invalid, - mapInvalid, mapValid, valid, isValid as isValid_, Validation } from "shared/lib/validation"; import * as opportunityValidation from "shared/lib/validation/opportunity/team-with-us"; -import { twuServiceAreaToTitleCase } from "front-end/lib/pages/opportunity/team-with-us/lib/index"; import * as genericValidation from "shared/lib/validation/opportunity/utility"; type RemoteOk = "yes" | "no"; @@ -58,6 +53,7 @@ const RemoteOkRadioGroup = RadioGroup.makeComponent(); export type TabId = | "Overview" + | "Resource Details" | "Description" | "Resource Questions" | "Scoring" @@ -82,10 +78,8 @@ export interface State { startDate: Immutable; completionDate: Immutable; maxBudget: Immutable; - serviceArea: Immutable; - targetAllocation: Immutable; - mandatorySkills: Immutable; - optionalSkills: Immutable; + // Resource Details Tab + resources: Immutable; // Description Tab description: Immutable; // Team Questions Tab @@ -97,6 +91,7 @@ export interface State { weightsTotal: Immutable; // Attachments tab attachments: Immutable; + preserveData: boolean; } export type Msg = @@ -112,10 +107,8 @@ export type Msg = | ADT<"startDate", DateField.Msg> | ADT<"completionDate", DateField.Msg> | ADT<"maxBudget", NumberField.Msg> - | ADT<"serviceArea", Select.Msg> - | ADT<"targetAllocation", Select.Msg> - | ADT<"mandatorySkills", SelectMulti.Msg> - | ADT<"optionalSkills", SelectMulti.Msg> + // Resource Details Tab + | ADT<"resources", Resources.Msg> // Description Tab | ADT<"description", RichMarkdownEditor.Msg> // Team Questions Tab @@ -158,21 +151,6 @@ export function setValidateDate( ); } -/** - * Local helper function to obtain and modify the key of - * (enum) TWUServiceArea if given the value. - * - * @see {@link TWUServiceArea} - * - * @param v - a value from the key/value pair of TWUServiceArea - * @returns - a single label/value pair for a select list - */ -function getSingleKeyValueOption(v: TWUServiceArea): Select.Option { - return { - label: twuServiceAreaToTitleCase(v), - value: v - }; -} /** * Initializes components on the page */ @@ -197,29 +175,17 @@ export const init: component_.base.Init = ({ "priceWeight", DEFAULT_PRICE_WEIGHT ); - const selectedTargetAllocationOption = opportunity?.targetAllocation - ? { - label: String(opportunity.targetAllocation), - value: String(opportunity.targetAllocation) - } - : null; - - /** - * Sets a single key/value pair for service area, or null - * - * @see {@link getSingleKeyValueOption} - */ - const serviceArea: Select.Option | null = (() => { - const v = opportunity?.serviceArea ? opportunity.serviceArea : null; - if (!v) { - return null; - } - return getSingleKeyValueOption(v as TWUServiceArea); - })(); + + // Used to flag when an opportunity is being created (undefined) or edited (draft/under review) + const isStatusNotDraftOrUnderReview = + opportunity?.status !== TWUOpportunityStatus.Draft && + opportunity?.status !== TWUOpportunityStatus.UnderReview && + opportunity?.status !== undefined; const [tabbedFormState, tabbedFormCmds] = TabbedFormComponent.init({ tabs: [ "Overview", + "Resource Details", "Description", "Resource Questions", "Scoring", @@ -353,83 +319,8 @@ export const init: component_.base.Init = ({ min: 1 } }); - const [serviceAreaState, serviceAreaCmds] = Select.init({ - errors: [], - validate: (option) => { - if (!option) { - return invalid(["Please select a Service Area."]); - } - return valid(option); - }, - child: { - value: serviceArea, - id: "twu-service-area", - options: Select.objectToOptions(TWUServiceArea) - } - }); - const [targetAllocationState, targetAllocationCmds] = Select.init({ - errors: [], - validate: (option) => { - if (!option) { - return invalid(["Please select a Resource Target Allocation."]); - } - return valid(option); - }, - child: { - value: selectedTargetAllocationOption ?? null, - id: "twu-opportunity-target-allocation", - options: adt( - "options", - [ - ...arrayFromRange(10, { - offset: 1, - step: 10, - cb: (number) => { - const value = String(number); - return { value, label: value }; - } - }) - ].reverse() - ) - } - }); - const [mandatorySkillsState, mandatorySkillsCmds] = SelectMulti.init({ - errors: [], - validate: (v) => { - const strings = v.map(({ value }) => value); - const validated0 = genericValidation.validateMandatorySkills(strings); - const validated1 = mapValid(validated0 as Validation, () => v); - return mapInvalid(validated1, (es) => flatten(es)); - }, - child: { - value: - opportunity?.mandatorySkills.map((value) => ({ - value, - label: value - })) || [], - id: "twu-opportunity-mandatory-skills", - creatable: true, - options: SelectMulti.stringsToOptions(SKILLS) - } - }); - const [optionalSkillsState, optionalSkillsCmds] = SelectMulti.init({ - errors: [], - validate: (v) => { - const strings = v.map(({ value }) => value); - const validated0 = opportunityValidation.validateOptionalSkills(strings); - const validated1 = mapValid(validated0 as Validation, () => v); - return mapInvalid(validated1, (es) => flatten(es)); - }, - child: { - value: - opportunity?.optionalSkills.map((value) => ({ - value, - label: value - })) || [], - id: "twu-opportunity-optional-skills", - creatable: true, - options: SelectMulti.stringsToOptions(SKILLS) - } + const [resourcesState, resourcesCmds] = Resources.init({ + resources: opportunity?.resources || [] }); const [descriptionState, descriptionCmds] = RichMarkdownEditor.init({ errors: [], @@ -517,17 +408,15 @@ export const init: component_.base.Init = ({ startDate: immutable(startDateState), completionDate: immutable(completionDateState), maxBudget: immutable(maxBudgetState), - serviceArea: immutable(serviceAreaState), - targetAllocation: immutable(targetAllocationState), - mandatorySkills: immutable(mandatorySkillsState), - optionalSkills: immutable(optionalSkillsState), + resources: immutable(resourcesState), description: immutable(descriptionState), resourceQuestions: immutable(resourceQuestionsState), questionsWeight: immutable(questionsWeightState), challengeWeight: immutable(challengeWeightState), priceWeight: immutable(priceWeightState), weightsTotal: immutable(weightsTotalState), - attachments: immutable(attachmentsState) + attachments: immutable(attachmentsState), + preserveData: isStatusNotDraftOrUnderReview }, [ ...component_.cmd.mapMany(tabbedFormCmds, (msg) => @@ -551,18 +440,7 @@ export const init: component_.base.Init = ({ adt("completionDate", msg) ), ...component_.cmd.mapMany(maxBudgetCmds, (msg) => adt("maxBudget", msg)), - ...component_.cmd.mapMany(serviceAreaCmds, (msg) => - adt("serviceArea", msg) - ), - ...component_.cmd.mapMany(targetAllocationCmds, (msg) => - adt("targetAllocation", msg) - ), - ...component_.cmd.mapMany(mandatorySkillsCmds, (msg) => - adt("mandatorySkills", msg) - ), - ...component_.cmd.mapMany(optionalSkillsCmds, (msg) => - adt("optionalSkills", msg) - ), + ...component_.cmd.mapMany(resourcesCmds, (msg) => adt("resources", msg)), ...component_.cmd.mapMany(descriptionCmds, (msg) => adt("description", msg) ), @@ -618,17 +496,8 @@ export function setErrors( .update("maxBudget", (s) => FormField.setErrors(s, errors.maxBudget || []) ) - .update("serviceArea", (s) => - FormField.setErrors(s, errors.serviceArea || []) - ) - .update("targetAllocation", (s) => - FormField.setErrors(s, errors.targetAllocation || []) - ) - .update("mandatorySkills", (s) => - FormField.setErrors(s, flatten(errors.mandatorySkills || [])) - ) - .update("optionalSkills", (s) => - FormField.setErrors(s, flatten(errors.optionalSkills || [])) + .update("resources", (s) => + Resources.setErrors(s, errors.resources || []) ) .update("description", (s) => FormField.setErrors(s, errors.description || []) @@ -660,10 +529,7 @@ export function validate(state: Immutable): Immutable { .update("startDate", (s) => FormField.validate(s)) .update("completionDate", (s) => FormField.validate(s)) .update("maxBudget", (s) => FormField.validate(s)) - .update("serviceArea", (s) => FormField.validate(s)) - .update("targetAllocation", (s) => FormField.validate(s)) - .update("mandatorySkills", (s) => FormField.validate(s)) - .update("optionalSkills", (s) => FormField.validate(s)) + .update("resources", (s) => Resources.validate(s)) .update("description", (s) => FormField.validate(s)) .update("resourceQuestions", (s) => ResourceQuestions.validate(s)) .update("questionsWeight", (s) => FormField.validate(s)) @@ -675,7 +541,7 @@ export function validate(state: Immutable): Immutable { /** * Certain form fields belong to different tabs on the page. - * This checks that all fields in the 'Overview' tab (1 of 5) are valid, meaning + * This checks that all fields in the 'Overview' tab (1 of 6) are valid, meaning * the state is an ADT. * * @param state @@ -693,16 +559,20 @@ export function isOverviewTabValid(state: Immutable): boolean { FormField.isValid(state.assignmentDate) && FormField.isValid(state.startDate) && FormField.isValid(state.completionDate) && - FormField.isValid(state.serviceArea) && - FormField.isValid(state.mandatorySkills) && - FormField.isValid(state.optionalSkills) && - FormField.isValid(state.maxBudget) && - FormField.isValid(state.targetAllocation) + FormField.isValid(state.maxBudget) ); } /** - * Checks that all fields in the 'Description' tab (2 of 5) are valid. + * Checks that all fields in the 'Resource Details' tab (2 of 6) are valid. + * @param state + */ +export function isResourceDetailsTabValid(state: Immutable): boolean { + return Resources.isValid(state.resources); +} + +/** + * Checks that all fields in the 'Description' tab (3 of 6) are valid. * * @param state * @returns @@ -712,7 +582,7 @@ export function isDescriptionTabValid(state: Immutable): boolean { } /** - * Checks that all fields in the 'Resource Questions' tab (3 of 5) are valid. + * Checks that all fields in the 'Resource Questions' tab (4 of 6) are valid. * * @param state * @returns @@ -722,7 +592,7 @@ export function isResourceQuestionsTabValid(state: Immutable): boolean { } /** - * Checks that all fields in the 'Scoring' tab (4 of 5) are valid. + * Checks that all fields in the 'Scoring' tab (5 of 6) are valid. * * @param state * @returns @@ -737,7 +607,7 @@ export function isScoringTabValid(state: Immutable): boolean { } /** - * Checks that all fields in the 'Attachments' tab (5 of 5) are valid. + * Checks that all fields in the 'Attachments' tab (6 of 6) are valid. * * @param state * @returns @@ -747,7 +617,7 @@ export function isAttachmentsTabValid(state: Immutable): boolean { } /** - * Checks if all (5) tabs have valid content + * Checks if all (6) tabs have valid content * * @param state * @returns boolean @@ -755,6 +625,7 @@ export function isAttachmentsTabValid(state: Immutable): boolean { export function isValid(state: Immutable): boolean { return ( isOverviewTabValid(state) && + isResourceDetailsTabValid(state) && isDescriptionTabValid(state) && isResourceQuestionsTabValid(state) && isScoringTabValid(state) && @@ -773,7 +644,9 @@ export function getNumberSelectValue(state: Immutable) { return isNaN(value) ? null : value; } -export type Values = Omit; +export type Values = Omit & { + resources: CreateTWUResourceBody[]; +}; /** * Where state is stored as an object, some types @@ -788,10 +661,10 @@ export function getValues(state: Immutable): Values { const challengeWeight = FormField.getValue(state.challengeWeight) || 0; const priceWeight = FormField.getValue(state.priceWeight) || 0; const maxBudget = FormField.getValue(state.maxBudget) || 0; - const targetAllocation = getNumberSelectValue(state.targetAllocation) || 0; const resourceQuestions = ResourceQuestions.getValues( state.resourceQuestions ); + const resources = Resources.getValues(state.resources); return { title: FormField.getValue(state.title), teaser: FormField.getValue(state.teaser), @@ -803,12 +676,7 @@ export function getValues(state: Immutable): Values { startDate: DateField.getValueAsString(state.startDate), completionDate: DateField.getValueAsString(state.completionDate), maxBudget, - serviceArea: - parseTWUServiceArea(Select.getValue(state.serviceArea)) ?? - TWUServiceArea.FullStackDeveloper, - targetAllocation, - mandatorySkills: SelectMulti.getValueAsStrings(state.mandatorySkills), - optionalSkills: SelectMulti.getValueAsStrings(state.optionalSkills), + resources, description: FormField.getValue(state.description), questionsWeight, challengeWeight, @@ -1130,40 +998,13 @@ export const update: component_.base.Update = ({ state, msg }) => { mapChildMsg: (value) => adt("maxBudget", value) }); - case "serviceArea": - return component_.base.updateChild({ - state, - childStatePath: ["serviceArea"], - childUpdate: Select.update, - childMsg: msg.value, - mapChildMsg: (value) => adt("serviceArea", value) - }); - - case "targetAllocation": + case "resources": return component_.base.updateChild({ state, - childStatePath: ["targetAllocation"], - childUpdate: Select.update, + childStatePath: ["resources"], + childUpdate: Resources.update, childMsg: msg.value, - mapChildMsg: (value) => adt("targetAllocation", value) - }); - - case "mandatorySkills": - return component_.base.updateChild({ - state, - childStatePath: ["mandatorySkills"], - childUpdate: SelectMulti.update, - childMsg: msg.value, - mapChildMsg: (value) => adt("mandatorySkills", value) - }); - - case "optionalSkills": - return component_.base.updateChild({ - state, - childStatePath: ["optionalSkills"], - childUpdate: SelectMulti.update, - childMsg: msg.value, - mapChildMsg: (value) => adt("optionalSkills", value) + mapChildMsg: (value) => adt("resources", value) }); case "description": @@ -1400,69 +1241,29 @@ const OverviewView: component_.base.View = ({ )} /> + + ); +}; - - - adt("serviceArea" as const, value) - )} - /> - - - - - adt("targetAllocation" as const, value) - )} - /> - - - - - adt("mandatorySkills" as const, value) - )} - /> - - +const ResourceDetailsView: component_.base.View = ({ + state, + dispatch, + disabled +}) => { + return ( + - - adt("optionalSkills" as const, value) + adt("resources" as const, value) )} + disabled={state.preserveData ? true : disabled} /> ); }; - const DescriptionView: component_.base.View = ({ state, dispatch, @@ -1622,6 +1423,8 @@ export const view: component_.base.View = (props) => { switch (TabbedForm.getActiveTab(state.tabbedForm)) { case "Overview": return ; + case "Resource Details": + return ; case "Description": return ; case "Resource Questions": @@ -1641,6 +1444,8 @@ export const view: component_.base.View = (props) => { switch (tab) { case "Overview": return isOverviewTabValid(state); + case "Resource Details": + return isResourceDetailsTabValid(state); case "Description": return isDescriptionTabValid(state); case "Resource Questions": diff --git a/src/front-end/typescript/lib/pages/opportunity/team-with-us/lib/components/resources.tsx b/src/front-end/typescript/lib/pages/opportunity/team-with-us/lib/components/resources.tsx new file mode 100644 index 000000000..a732fe8e1 --- /dev/null +++ b/src/front-end/typescript/lib/pages/opportunity/team-with-us/lib/components/resources.tsx @@ -0,0 +1,564 @@ +import { Col, Row } from "reactstrap"; +import Link, { iconLinkSymbol, leftPlacement } from "front-end/lib/views/link"; +import { ADT, adt } from "shared/lib/types"; +import React from "react"; +import { + component as component_, + immutable, + Immutable +} from "front-end/lib/framework"; +import * as Select from "front-end/lib/components/form-field/select"; +import * as SelectMulti from "front-end/lib/components/form-field/select-multi"; +import { + CreateTWUResourceValidationErrors, + TWUServiceArea, + TWUResource, + parseTWUServiceArea, + CreateTWUResourceBody +} from "shared/lib/resources/opportunity/team-with-us"; +import { + Validation, + invalid, + mapInvalid, + mapValid, + valid +} from "shared/lib/validation"; +import * as genericValidation from "shared/lib/validation/opportunity/utility"; +import { twuServiceAreaToTitleCase } from "front-end/lib/pages/opportunity/team-with-us/lib"; +import { + arrayFromRange, + arrayContainsGreaterThan1Check as isRemovalPermitted +} from "shared/lib"; +import * as FormField from "front-end/lib/components/form-field"; +import { getNumberSelectValue } from "front-end/lib/pages/opportunity/team-with-us/lib/components/form"; +import { flatten } from "lodash"; +import SKILLS from "shared/lib/data/skills"; +import ALL_SERVICE_AREAS from "shared/lib/data/service-areas"; + +interface Resource { + serviceArea: Immutable; + targetAllocation: Immutable; + mandatorySkills: Immutable; + optionalSkills: Immutable; + removeable: boolean; +} + +export interface State { + resources: Resource[]; +} + +export type Msg = + | ADT<"addResource"> + | ADT<"deleteResource", number> + | ADT<"serviceArea", { childMsg: Select.Msg; rIndex: number }> + | ADT<"targetAllocation", { childMsg: Select.Msg; rIndex: number }> + | ADT<"mandatorySkills", { childMsg: SelectMulti.Msg; rIndex: number }> + | ADT<"optionalSkills", { childMsg: SelectMulti.Msg; rIndex: number }>; + +export interface Params { + resources: TWUResource[]; +} + +interface Props extends component_.base.ComponentViewProps { + disabled?: boolean; +} + +/** + * Local helper function to obtain and modify the key of + * (enum) TWUServiceArea if given the value. + * + * @see {@link TWUServiceArea} + * + * @param v - a value from the key/value pair of TWUServiceArea + * @returns - a single label/value pair for a select list + */ +function getSingleKeyValueOption(v: TWUServiceArea): Select.Option { + return { + label: twuServiceAreaToTitleCase(v), + value: v + }; +} + +function createResource( + rIndex: number, + resource?: TWUResource +): component_.base.InitReturnValue { + const idNamespace = String(Math.random()); + /** + * Sets a single key/value pair for service area, or null + * + * @see {@link getSingleKeyValueOption} + */ + const serviceArea: Select.Option | null = (() => { + const v = resource?.serviceArea; + if (!v) { + return null; + } + return getSingleKeyValueOption(v); + })(); + const selectedTargetAllocationOption = resource?.targetAllocation + ? { + label: String(resource.targetAllocation), + value: String(resource.targetAllocation) + } + : null; + + const [serviceAreaState, serviceAreaCmds] = Select.init({ + errors: [], + validate: (option) => { + if (!option) { + return invalid(["Please select a Service Area."]); + } + return valid(option); + }, + child: { + value: serviceArea, + id: `${idNamespace}-twu-resource-service-area`, + options: Select.objectToOptions( + ALL_SERVICE_AREAS.reduce>( + (acc, serviceArea) => ({ ...acc, [serviceArea]: serviceArea }), + {} + ), + twuServiceAreaToTitleCase + ) + } + }); + const [targetAllocationState, targetAllocationCmds] = Select.init({ + errors: [], + validate: (option) => { + if (!option) { + return invalid(["Please select a Resource Target Allocation."]); + } + return valid(option); + }, + child: { + value: selectedTargetAllocationOption ?? null, + id: `${idNamespace}-twu-resource-target-allocation`, + options: adt( + "options", + [ + ...arrayFromRange(10, { + offset: 1, + step: 10, + cb: (number) => { + const value = String(number); + return { value, label: value }; + } + }) + ].reverse() + ) + } + }); + const [mandatorySkillsState, mandatorySkillsCmds] = SelectMulti.init({ + errors: [], + validate: (v) => { + const strings = v.map(({ value }) => value); + const validated0 = genericValidation.validateMandatorySkills(strings); + const validated1 = mapValid(validated0 as Validation, () => v); + return mapInvalid(validated1, (es) => flatten(es)); + }, + child: { + value: + resource?.mandatorySkills.map((value) => ({ + value, + label: value + })) ?? [], + id: `${idNamespace}-twu-resource-twu-mandatory-skills`, + creatable: true, + options: SelectMulti.stringsToOptions(SKILLS) + } + }); + const [optionalSkillsState, optionalSkillsCmds] = SelectMulti.init({ + errors: [], + validate: (v) => { + const strings = v.map(({ value }) => value); + const validated0 = genericValidation.validateOptionalSkills(strings); + const validated1 = mapValid(validated0 as Validation, () => v); + return mapInvalid(validated1, (es) => flatten(es)); + }, + child: { + value: + resource?.optionalSkills.map((value) => ({ + value, + label: value + })) ?? [], + id: `${idNamespace}-twu-resource-optional-skills`, + creatable: true, + options: SelectMulti.stringsToOptions(SKILLS) + } + }); + return [ + { + serviceArea: immutable(serviceAreaState), + targetAllocation: immutable(targetAllocationState), + mandatorySkills: immutable(mandatorySkillsState), + optionalSkills: immutable(optionalSkillsState), + removeable: true + }, + [ + ...component_.cmd.mapMany( + serviceAreaCmds, + (childMsg) => adt("serviceArea", { childMsg, rIndex }) as Msg + ), + ...component_.cmd.mapMany( + targetAllocationCmds, + (childMsg) => + adt("targetAllocation", { + childMsg, + rIndex + }) as Msg + ), + ...component_.cmd.mapMany( + mandatorySkillsCmds, + (childMsg) => adt("mandatorySkills", { childMsg, rIndex }) as Msg + ), + ...component_.cmd.mapMany( + optionalSkillsCmds, + (childMsg) => adt("optionalSkills", { childMsg, rIndex }) as Msg + ) + ] + ]; +} + +export type Errors = CreateTWUResourceValidationErrors[]; +export function setErrors( + state: Immutable, + errors: Errors = [] +): Immutable { + return errors.reduce((acc, e, i) => { + return acc + .updateIn(["resources", i, "serviceArea"], (s) => + FormField.setErrors(s as Immutable, e.serviceArea || []) + ) + .updateIn(["resources", i, "targetAllocation"], (s) => + FormField.setErrors( + s as Immutable, + e.targetAllocation || [] + ) + ) + .updateIn(["resources", i, "mandatorySkills"], (s) => + FormField.setErrors( + s as Immutable, + flatten(e.mandatorySkills ?? []) + ) + ) + .updateIn(["resources", i, "optionalSkills"], (s) => + FormField.setErrors( + s as Immutable, + flatten(e.optionalSkills ?? []) + ) + ); + }, state); +} + +export function validate(state: Immutable): Immutable { + return state.resources.reduce((acc, r, i) => { + return acc + .updateIn(["resources", i, "serviceArea"], (s) => + FormField.validate(s as Immutable) + ) + .updateIn(["resources", i, "targetAllocation"], (s) => + FormField.validate(s as Immutable) + ) + .updateIn(["resources", i, "mandatorySkills"], (s) => + FormField.validate(s as Immutable) + ) + .updateIn(["resources", i, "optionalSkills"], (s) => + FormField.validate(s as Immutable) + ); + }, state); +} + +export function isValid(state: Immutable): boolean { + if (!state.resources.length) { + return false; + } + return state.resources.reduce((acc, r) => { + return ( + acc && + FormField.isValid(r.serviceArea) && + FormField.isValid(r.targetAllocation) && + FormField.isValid(r.mandatorySkills) && + FormField.isValid(r.optionalSkills) + ); + }, true as boolean); +} + +export type Values = CreateTWUResourceBody[]; + +export function getValues(state: Immutable): Values { + return state.resources.reduce((acc, r, order) => { + if (!acc) { + return acc; + } + acc.push({ + serviceArea: + parseTWUServiceArea(Select.getValue(r.serviceArea)) ?? + TWUServiceArea.FullStackDeveloper, + targetAllocation: getNumberSelectValue(r.targetAllocation) || 0, + mandatorySkills: SelectMulti.getValueAsStrings(r.mandatorySkills), + optionalSkills: SelectMulti.getValueAsStrings(r.optionalSkills), + order: order + }); + return acc; + }, []); +} + +export const init: component_.base.Init = (params) => { + const [defaultResource, defaultCmds] = createResource(0); + const [resources, cmds] = params.resources.length + ? params.resources + .map((resource, index) => createResource(index, resource)) + .reduce( + ([accResources, accCmds], [r, cs]) => [ + [...accResources, r], + [...accCmds, ...cs] + ], + [[], []] as component_.base.InitReturnValue + ) + : [[defaultResource], defaultCmds]; + return [ + { + resources: resources.map((resource, _, currentResources) => ({ + ...resource, + removeable: isRemovalPermitted(currentResources) + })) + }, + cmds + ]; +}; + +export const update: component_.base.Update = ({ state, msg }) => { + switch (msg.tag) { + case "addResource": { + const [resource, cmds] = createResource(state.resources.length); + const currentResources = [...state.resources, resource]; + return [ + state.set( + "resources", + currentResources.map((resource) => ({ + ...resource, + removeable: isRemovalPermitted(currentResources) + })) + ), + cmds + ]; + } + + case "deleteResource": { + return [ + state.set( + "resources", + state.resources + .reduce((acc, r, index) => { + return index === msg.value ? acc : [...acc, r]; + }, [] as Resource[]) + .map((resource, _, resources) => { + return { + ...resource, + removeable: isRemovalPermitted(resources) + }; + }) + ), + [] + ]; + } + + case "serviceArea": { + const componentMessage = msg.value.childMsg; + const rIndex = msg.value.rIndex; + return component_.base.updateChild({ + state, + childStatePath: ["resources", `${rIndex}`, "serviceArea"], + childUpdate: Select.update, + childMsg: componentMessage, + mapChildMsg: (value) => adt("serviceArea", { rIndex, childMsg: value }) + }); + } + + case "targetAllocation": { + const { childMsg, rIndex } = msg.value; + return component_.base.updateChild({ + state, + childStatePath: ["resources", `${rIndex}`, "targetAllocation"], + childUpdate: Select.update, + childMsg, + mapChildMsg: (value) => + adt("targetAllocation", { rIndex, childMsg: value }) + }); + } + + case "mandatorySkills": { + const { childMsg, rIndex } = msg.value; + return component_.base.updateChild({ + state, + childStatePath: ["resources", `${rIndex}`, "mandatorySkills"], + childUpdate: SelectMulti.update, + childMsg, + mapChildMsg: (value) => + adt("mandatorySkills", { rIndex, childMsg: value }) + }); + } + + case "optionalSkills": { + const { childMsg, rIndex } = msg.value; + return component_.base.updateChild({ + state, + childStatePath: ["resources", `${rIndex}`, "optionalSkills"], + childUpdate: SelectMulti.update, + childMsg, + mapChildMsg: (value) => + adt("optionalSkills", { rIndex, childMsg: value }) + }); + } + } +}; + +interface ResourceViewProps { + index: number; + state: Resource; + dispatch: component_.base.Dispatch; + disabled?: boolean; +} + +const ResourceView: component_.base.View = (props) => { + const { index, state, dispatch, disabled } = props; + return ( +
0 ? "pt-5 mt-5 border-top" : ""}> + + +
+

Resource {index + 1}

+ {state.removeable ? ( + disabled ? null : ( + dispatch(adt("deleteResource", index))}> + Delete + + ) + ) : null} +
+ +
+ + + + adt("serviceArea" as const, { childMsg: value, rIndex: index }) + )} + /> + + + + + adt("targetAllocation" as const, { + childMsg: value, + rIndex: index + }) + )} + /> + + + + + adt("mandatorySkills" as const, { + childMsg: value, + rIndex: index + }) + )} + /> + + + + + adt("optionalSkills" as const, { childMsg: value, rIndex: index }) + )} + /> + + +
+ ); +}; +const AddButton: component_.base.View = ({ + state, + disabled, + dispatch +}) => { + if (disabled) { + return null; + } + return ( + + +
+ { + dispatch(adt("addResource")); + }}> + Add a Resource + +
+ +
+ ); +}; + +export const view: component_.base.View = (props) => { + const { state, disabled } = props; + return ( +
+ {state.resources.map((resource, i) => ( + + ))} + +
+ ); +}; diff --git a/src/front-end/typescript/lib/pages/opportunity/team-with-us/lib/index.ts b/src/front-end/typescript/lib/pages/opportunity/team-with-us/lib/index.ts index 38f964bfe..a5ad144a4 100644 --- a/src/front-end/typescript/lib/pages/opportunity/team-with-us/lib/index.ts +++ b/src/front-end/typescript/lib/pages/opportunity/team-with-us/lib/index.ts @@ -1,6 +1,6 @@ import * as History from "front-end/lib/components/table/history"; import { ThemeColor } from "front-end/lib/types"; -import { startCase } from "lodash"; +import { flatten, startCase, uniq } from "lodash"; import { isOpen, TWUOpportunity, @@ -175,7 +175,28 @@ export function opportunityToHistoryItems({ * @returns */ export function twuServiceAreaToTitleCase(s: TWUServiceArea) { + if (s === TWUServiceArea.DevopsSpecialist) { + return "DevOps Specialist"; + } return startCase( Object.keys(TWUServiceArea)[Object.values(TWUServiceArea).indexOf(s)] ); } + +/** + * Flattens and removes duplicates from a TWU opportunity's mandatory and + * optional skills. + * + * @param opp TWU opportunity with resources + * @returns object containing aggregated mandatory and optional skills + */ +export function aggregateResourceSkills(opp: TWUOpportunity): { + mandatory: string[]; + optional: string[]; +} { + const [mandatory, optional] = (["mandatorySkills", "optionalSkills"] as const) + .map((skill) => opp.resources.map((resource) => resource[skill])) + .map((skills) => uniq(flatten(skills))); + + return { mandatory, optional }; +} diff --git a/src/front-end/typescript/lib/pages/opportunity/team-with-us/view.tsx b/src/front-end/typescript/lib/pages/opportunity/team-with-us/view.tsx index 3585540f5..7c50d4f52 100644 --- a/src/front-end/typescript/lib/pages/opportunity/team-with-us/view.tsx +++ b/src/front-end/typescript/lib/pages/opportunity/team-with-us/view.tsx @@ -5,9 +5,14 @@ import { makeStopLoading } from "front-end/lib"; import { Route, SharedState } from "front-end/lib/app/types"; +import * as Table from "front-end/lib/components/table"; import { AddendaList } from "front-end/lib/components/addenda"; import { AttachmentList } from "front-end/lib/components/attachments"; -import { component as component_ } from "front-end/lib/framework"; +import { + Immutable, + immutable, + component as component_ +} from "front-end/lib/framework"; import * as api from "front-end/lib/http/api"; import { OpportunityBadge } from "front-end/lib/views/badge"; import DateMetadata from "front-end/lib/views/date-metadata"; @@ -42,8 +47,9 @@ import { Content } from "shared/lib/resources/content"; import { OrganizationSlim, doesOrganizationMeetTWUQualification, - doesOrganizationProvideServiceArea + doesOrganizationProvideServiceAreas } from "shared/lib/resources/organization"; +import { aggregateResourceSkills } from "front-end/lib/pages/opportunity/team-with-us/lib"; type InfoTab = "details" | "competitionRules" | "attachments" | "addenda"; @@ -61,6 +67,7 @@ export interface State { routePath: string; competitionRulesContent: string; qualification: Qualification; + table: Immutable; } export type InnerMsg = @@ -77,7 +84,8 @@ export type InnerMsg = > | ADT<"toggleWatch"> | ADT<"onToggleWatchResponse", boolean> - | ADT<"setActiveInfoTab", InfoTab>; + | ADT<"setActiveInfoTab", InfoTab> + | ADT<"table", Table.Msg>; export type Msg = component_.page.Msg; @@ -94,6 +102,9 @@ const init: component_.page.Init< > = ({ routeParams, shared, routePath }) => { const { opportunityId } = routeParams; const viewerUser = shared.session?.user || null; + const [tableState, tableCmds] = Table.init({ + idNamespace: "resources-table" + }); return [ { toggleWatchLoading: 0, @@ -103,7 +114,8 @@ const init: component_.page.Init< activeInfoTab: "details", routePath, competitionRulesContent: "", - qualification: "notQualified" + qualification: "notQualified", + table: immutable(tableState) }, [ api.counters.update()( @@ -137,7 +149,8 @@ const init: component_.page.Init< competitionRulesContentResponse, organizationsResponse ]) as Msg - ) + ), + ...component_.cmd.mapMany(tableCmds, (msg) => adt("table", msg) as Msg) ] ]; }; @@ -192,9 +205,9 @@ const update: component_.page.Update = ({ (qualification: Qualification, organization) => { if (doesOrganizationMeetTWUQualification(organization)) { if ( - doesOrganizationProvideServiceArea( + doesOrganizationProvideServiceAreas( organization, - opportunity.serviceArea + opportunity.resources ) ) { return "qualifiedCorrectServiceArea"; @@ -243,6 +256,14 @@ const update: component_.page.Update = ({ } return [stopToggleWatchLoading(state), []]; } + case "table": + return component_.base.updateChild({ + state, + childStatePath: ["table", "state"], + childUpdate: Table.update, + childMsg: msg.value, + mapChildMsg: (value) => ({ tag: "table", value }) + }); default: return [state, []]; } @@ -373,18 +394,18 @@ const Header: component_.base.ComponentView = ({ xs="6" className="d-flex justify-content-start align-items-start flex-nowrap"> @@ -393,18 +414,18 @@ const Header: component_.base.ComponentView = ({ xs="6" className="d-flex justify-content-start align-items-start flex-nowrap"> @@ -433,28 +454,82 @@ const InfoDetailsHeading: component_.base.View<{ ); }; -const InfoDetails: component_.base.ComponentView = ({ state }) => { +function resourceTableHeadCells(): Table.HeadCells { + return [ + { + children: "Resource", + className: "text-nowrap", + style: { width: "100%" } + }, + { + children: "Allocation %", + className: "text-nowrap text-center", + style: { width: "0px" } + } + ]; +} + +function resourceTableBodyRows(state: Immutable): Table.BodyRows { + return ( + state.opportunity?.resources.map(({ serviceArea, targetAllocation }) => [ + { children: startCase(lowerCase(serviceArea)) }, + { children: targetAllocation.toString(), className: "text-center" } + ]) ?? [] + ); +} + +const ResourcesTable: component_.base.ComponentView = ({ + state, + dispatch +}) => { + return ( + + adt("table" as const, msg) + )} + hover={false} + /> + ); +}; + +const InfoDetails: component_.base.ComponentView = ({ + state, + dispatch +}) => { const opp = state.opportunity; if (!opp) return null; + + const skills = aggregateResourceSkills(opp); + return (

Details

+ + + +

To submit a proposal for this opportunity, you must possess the following skills:

- - {opp.optionalSkills.length ? ( + + {skills.optional.length ? (

Additionally, possessing the following skills would be considered a bonus:

- +
) : null} @@ -852,8 +927,8 @@ export const component: component_.page.Component< Qualified Supplier {" "} - for this Service Area in order to submit a proposal to this - opportunity. + for these Service Areas in order to submit a proposal to + this opportunity. ) }); diff --git a/src/front-end/typescript/lib/pages/organization/edit/tab/twu-qualification.tsx b/src/front-end/typescript/lib/pages/organization/edit/tab/twu-qualification.tsx index 71eda4d6a..a90fc64a1 100644 --- a/src/front-end/typescript/lib/pages/organization/edit/tab/twu-qualification.tsx +++ b/src/front-end/typescript/lib/pages/organization/edit/tab/twu-qualification.tsx @@ -31,8 +31,10 @@ import { kebabCase } from "lodash"; import { TWUServiceAreaRecord } from "shared/lib/resources/service-area"; import { TWU_BC_BID_URL } from "front-end/config"; import ALL_SERVICE_AREAS from "shared/lib/data/service-areas"; +import { TWUServiceArea } from "shared/lib/resources/opportunity/team-with-us"; +import { twuServiceAreaToTitleCase } from "front-end/lib/pages/opportunity/team-with-us/lib"; -type AvailableServiceArea = typeof ALL_SERVICE_AREAS[number]; +type AvailableServiceArea = { serviceArea: TWUServiceArea; name: string }; interface ServiceArea extends AvailableServiceArea { checkbox: Immutable; @@ -66,7 +68,10 @@ export type Msg = component_.page.Msg; function resetQualifiedServiceAreas( organizationServiceAreas: TWUServiceAreaRecord[] ) { - return ALL_SERVICE_AREAS.reduce<[ServiceArea[], component_.Cmd[]]>( + return ALL_SERVICE_AREAS.map((serviceArea) => ({ + serviceArea, + name: twuServiceAreaToTitleCase(serviceArea) + })).reduce<[ServiceArea[], component_.Cmd[]]>( ([serviceAreas, serviceAreasCmds], serviceArea, index) => { const [checkboxState, checkboxCmds] = Checkbox.init({ errors: [], diff --git a/src/front-end/typescript/lib/pages/proposal/team-with-us/create.tsx b/src/front-end/typescript/lib/pages/proposal/team-with-us/create.tsx index 48206c363..dfbae3f8d 100644 --- a/src/front-end/typescript/lib/pages/proposal/team-with-us/create.tsx +++ b/src/front-end/typescript/lib/pages/proposal/team-with-us/create.tsx @@ -509,16 +509,8 @@ export const component: component_.page.Component< }), getModal: getModalValid((state) => { - const form = state.form; const opportunity = state.opportunity; - if (!form || !opportunity) return component_.page.modal.hide(); - const formModal = component_.page.modal.map( - Form.getModal(form), - (msg) => adt("form", msg) as Msg - ); - if (formModal.tag !== "hide") { - return formModal; - } + if (!opportunity) return component_.page.modal.hide(); const hasAcceptedTerms = SubmitProposalTerms.getProposalCheckbox(state.submitTerms) && SubmitProposalTerms.getAppCheckbox(state.submitTerms); diff --git a/src/front-end/typescript/lib/pages/proposal/team-with-us/edit/tab/proposal.tsx b/src/front-end/typescript/lib/pages/proposal/team-with-us/edit/tab/proposal.tsx index d6febbb49..48af683e9 100644 --- a/src/front-end/typescript/lib/pages/proposal/team-with-us/edit/tab/proposal.tsx +++ b/src/front-end/typescript/lib/pages/proposal/team-with-us/edit/tab/proposal.tsx @@ -806,13 +806,6 @@ export const component: Tab.Component = { }, getModal: (state) => { - const formModal = component_.page.modal.map( - Form.getModal(state.form), - (msg) => adt("form", msg) as Msg - ); - if (formModal.tag !== "hide") { - return formModal; - } const hasAcceptedTerms = SubmitProposalTerms.getProposalCheckbox(state.submitTerms) && SubmitProposalTerms.getAppCheckbox(state.submitTerms); diff --git a/src/front-end/typescript/lib/pages/proposal/team-with-us/lib/components/form.tsx b/src/front-end/typescript/lib/pages/proposal/team-with-us/lib/components/form.tsx index 39c274101..aa40a29a5 100644 --- a/src/front-end/typescript/lib/pages/proposal/team-with-us/lib/components/form.tsx +++ b/src/front-end/typescript/lib/pages/proposal/team-with-us/lib/components/form.tsx @@ -4,7 +4,6 @@ import { } from "front-end/config"; import { fileBlobPath, makeStartLoading, makeStopLoading } from "front-end/lib"; import * as FormField from "front-end/lib/components/form-field"; -import * as NumberField from "front-end/lib/components/form-field/number"; import * as Select from "front-end/lib/components/form-field/select"; import * as TabbedForm from "front-end/lib/components/tabbed-form"; import { @@ -24,7 +23,7 @@ import Markdown, { ProposalMarkdown } from "front-end/lib/views/markdown"; import { find } from "lodash"; import React from "react"; import { Alert, Col, Row } from "reactstrap"; -import { formatDate } from "shared/lib"; +import { formatAmount, formatDate } from "shared/lib"; import { isTWUOpportunityAcceptingProposals, TWUOpportunity @@ -32,7 +31,7 @@ import { import { OrganizationSlim, doesOrganizationMeetTWUQualification, - doesOrganizationProvideServiceArea + doesOrganizationProvideServiceAreas } from "shared/lib/resources/organization"; import { CreateRequestBody, @@ -45,16 +44,14 @@ import { import { User, UserType } from "shared/lib/resources/user"; import { adt, ADT, Id } from "shared/lib/types"; import { invalid, valid, Validation } from "shared/lib/validation"; -import * as proposalValidation from "shared/lib/validation/proposal/team-with-us"; import { AffiliationMember } from "shared/lib/resources/affiliation"; import * as Team from "front-end/lib/pages/proposal/team-with-us/lib/components/team"; -import { makeViewTeamMemberModal } from "front-end/lib/pages/organization/lib/views/team-member"; import { userAvatarPath } from "front-end/lib/pages/user/lib"; +import { twuServiceAreaToTitleCase } from "front-end/lib/pages/opportunity/team-with-us/lib"; export type TabId = | "Evaluation" - | "Resource" - | "Pricing" + | "Team Members" | "Questions" | "Review Proposal"; @@ -73,23 +70,18 @@ export function getActiveTab(state: Immutable): TabId { return TabbedForm.getActiveTab(state.tabbedForm); } -type ModalId = ADT<"viewTeamMember", Team.Member>; - export interface State extends Pick< Params, "viewerUser" | "opportunity" | "evaluationContent" | "organizations" > { proposal: TWUProposal | null; - showModal: ModalId | null; getAffiliationsLoading: number; tabbedForm: Immutable>; viewerUser: User; // Team Tab organization: Immutable; team: Immutable; - // Pricing Tab - hourlyRate: Immutable; // Questions Tab resourceQuestions: Immutable; // Review Proposal Tab @@ -100,14 +92,10 @@ export interface State export type Msg = | ADT<"onInitResponse", AffiliationMember[]> | ADT<"tabbedForm", TabbedForm.Msg> - | ADT<"showModal", ModalId> - | ADT<"hideModal"> // Team Tab | ADT<"organization", Select.Msg> | ADT<"onGetAffiliationsResponse", [Id, AffiliationMember[]]> | ADT<"team", Team.Msg> - // Pricing Tab - | ADT<"hourlyRate", NumberField.Msg> // Questions Tab | ADT<"resourceQuestions", ResourceQuestions.Msg> // Review Proposal Tab @@ -151,11 +139,9 @@ export const init: component_.base.Init = ({ .filter( (o) => doesOrganizationMeetTWUQualification(o) && - doesOrganizationProvideServiceArea(o, opportunity.serviceArea) + doesOrganizationProvideServiceAreas(o, opportunity.resources) ) .map(({ id, legalName }) => ({ label: legalName, value: id })); - // TODO: hourlyRate will need to be set differently after TWU moves away from a one-and-only-one-resource world - const hourlyRate = proposal?.team?.length ? proposal.team[0].hourlyRate : 0; const selectedOrganizationOption = proposal?.organization ? { label: proposal.organization.legalName, @@ -163,7 +149,7 @@ export const init: component_.base.Init = ({ } : null; const [tabbedFormState, tabbedFormCmds] = TabbedFormComponent.init({ - tabs: ["Evaluation", "Resource", "Pricing", "Questions", "Review Proposal"], + tabs: ["Evaluation", "Team Members", "Questions", "Review Proposal"], activeTab }); const [organizationState, organizationCmds] = Select.init({ @@ -190,24 +176,11 @@ export const init: component_.base.Init = ({ const [teamState, teamCmds] = Team.init({ orgId: proposal?.organization?.id, - affiliations: [], // Re-initialize with affiliations once loaded. - proposalTeam: proposal?.team || [] + affiliations: [], // Set members with affiliations once loaded. + proposalTeam: proposal?.team || [], + resources: opportunity.resources }); - const [hourlyRateState, hourlyRateCmds] = NumberField.init({ - errors: [], - validate: (v) => { - if (v === null) { - return invalid([`Please enter a valid hourly rate.`]); - } - return proposalValidation.validateTWUHourlyRate(v); - }, - child: { - value: hourlyRate || null, - id: "twu-proposal-cost", - min: 1 - } - }); const [resourceQuestionsState, resourceQuestionsCmds] = ResourceQuestions.init({ questions: opportunity.resourceQuestions, @@ -228,7 +201,6 @@ export const init: component_.base.Init = ({ tabbedForm: immutable(tabbedFormState), organization: immutable(organizationState), team: immutable(teamState), - hourlyRate: immutable(hourlyRateState), resourceQuestions: immutable(resourceQuestionsState), existingProposalForOrganizationError: null }, @@ -240,9 +212,6 @@ export const init: component_.base.Init = ({ adt("organization", msg) ), ...component_.cmd.mapMany(teamCmds, (msg) => adt("team", msg)), - ...component_.cmd.mapMany(hourlyRateCmds, (msg) => - adt("hourlyRate", msg) - ), ...component_.cmd.mapMany(resourceQuestionsCmds, (msg) => adt("resourceQuestions", msg) ), @@ -265,50 +234,37 @@ export function setErrors( : errors?.existingOrganizationProposal ? errors.existingOrganizationProposal.errors : []; - return ( - state - .update("organization", (s) => FormField.setErrors(s, organizationErrors)) - // TODO: Populate the rest of the form errors correctly. - // .update( "team", (s) => - // Team.setErrors(s, { - // team.errors?team - // }) - // ) - // .update("hourlyRate", (s) => - // FormField.setErrors( - // s, - // (errors && (errors as CreateValidationErrors).hourlyRate) || [] - // ) - // ) - .update("resourceQuestions", (s) => - ResourceQuestions.setErrors( - s, - (errors && - (errors as CreateValidationErrors).resourceQuestionResponses) || - [] - ) + return state + .update("organization", (s) => FormField.setErrors(s, organizationErrors)) + .update("team", (s) => + Team.setErrors( + s, + (errors && (errors as CreateValidationErrors).team) || [] ) - .set( - "existingProposalForOrganizationError", - errors?.existingOrganizationProposal - ? errors.existingOrganizationProposal.proposalId - : null + ) + .update("resourceQuestions", (s) => + ResourceQuestions.setErrors( + s, + (errors && + (errors as CreateValidationErrors).resourceQuestionResponses) || + [] ) - ); + ) + .set( + "existingProposalForOrganizationError", + errors?.existingOrganizationProposal + ? errors.existingOrganizationProposal.proposalId + : null + ); } export function validate(state: Immutable): Immutable { return state .update("organization", (s) => FormField.validate(s)) .update("team", (s) => Team.validate(s)) - .update("hourlyRate", (s) => FormField.validate(s)) .update("resourceQuestions", (s) => ResourceQuestions.validate(s)); } -export function isPricingTabValid(state: Immutable): boolean { - return FormField.isValid(state.hourlyRate); -} - export function isOrganizationsTabValid(state: Immutable): boolean { return FormField.isValid(state.organization) && Team.isValid(state.team); } @@ -317,11 +273,7 @@ export function isResourceQuestionsTabValid(state: Immutable): boolean { } export function isValid(state: Immutable): boolean { - return ( - isPricingTabValid(state) && - isResourceQuestionsTabValid(state) && - isOrganizationsTabValid(state) - ); + return isResourceQuestionsTabValid(state) && isOrganizationsTabValid(state); } export function isLoading(state: Immutable): boolean { @@ -332,10 +284,9 @@ export type Values = Omit; export function getValues(state: Immutable): Values { const organization = FormField.getValue(state.organization); - const hourlyRate = FormField.getValue(state.hourlyRate) || 0; const team = Team.getValues(state.team); return { - team: team.map((member) => ({ member: member.id, hourlyRate })), + team, attachments: [], opportunity: state.opportunity.id, organization: organization?.value, @@ -432,13 +383,13 @@ export const update: component_.base.Update = ({ state, msg }) => { switch (msg.tag) { case "onInitResponse": { const affiliations = msg.value; - const [teamState, teamCmds] = Team.init({ - orgId: state.proposal?.organization?.id, + const [teamState, teamCmds] = Team.setStaff( + state.team, affiliations, - proposalTeam: state.proposal?.team || [] - }); + state.proposal?.organization?.id ?? null + ); return [ - state.set("team", immutable(teamState)), + state.set("team", teamState), component_.cmd.mapMany(teamCmds, (msg) => adt("team", msg) as Msg) ]; } @@ -452,12 +403,6 @@ export const update: component_.base.Update = ({ state, msg }) => { mapChildMsg: (value) => adt("tabbedForm", value) }); - case "showModal": - return [state.set("showModal", msg.value), []]; - - case "hideModal": - return [state.set("showModal", null), []]; - case "organization": return component_.base.updateChild({ state, @@ -492,10 +437,17 @@ export const update: component_.base.Update = ({ state, msg }) => { case "onGetAffiliationsResponse": { const [orgId, affiliations] = msg.value; - state = state.update("team", (t) => - Team.setAffiliations(t, affiliations, orgId) - ); - return [stopGetAffiliationsLoading(state), []]; + const [teamState, teamCmds] = Team.init({ + orgId, + affiliations, + proposalTeam: [], // Re-initialize Team component when switching orgs. + resources: state.opportunity.resources + }); + state = state.set("team", immutable(teamState)); + return [ + stopGetAffiliationsLoading(state), + component_.cmd.mapMany(teamCmds, (msg) => adt("team", msg) as Msg) + ]; } case "team": @@ -507,15 +459,6 @@ export const update: component_.base.Update = ({ state, msg }) => { mapChildMsg: (value) => adt("team", value) }); - case "hourlyRate": - return component_.base.updateChild({ - state, - childStatePath: ["hourlyRate"], - childUpdate: NumberField.update, - childMsg: msg.value, - mapChildMsg: (value) => adt("hourlyRate", value) - }); - case "resourceQuestions": return component_.base.updateChild({ state, @@ -613,7 +556,9 @@ const OrganizationView: component_.base.View = ({ consideration, you must: