From a7357642f2672bc846b1148c25503459b050a335 Mon Sep 17 00:00:00 2001 From: ClayBenson94 Date: Mon, 4 Nov 2024 12:17:02 -0500 Subject: [PATCH 01/22] Empty commit to start feature branch From 63491a19d3b753efb984bceacf468e2cc568c61d Mon Sep 17 00:00:00 2001 From: Nick Downey <68014929+downeyn-cms@users.noreply.github.com> Date: Wed, 13 Nov 2024 11:28:05 -0500 Subject: [PATCH 02/22] Add new discussions i18n file (#2886) --- src/i18n/en-US/discussions.ts | 80 +++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/i18n/en-US/discussions.ts diff --git a/src/i18n/en-US/discussions.ts b/src/i18n/en-US/discussions.ts new file mode 100644 index 0000000000..4e7938daea --- /dev/null +++ b/src/i18n/en-US/discussions.ts @@ -0,0 +1,80 @@ +const discussions = { + // TODO: We need to make a decision on how to structure these translations: + // - Do we want a generic i18n file at all? + // - Should some board specific stuff be stored in their respective i18n files? (e.g. name of the board, participants, role type translations, etc. in grbReview.ts) + // - I tried to make the general section as generic as possible (obviously lol) but some of it may make the frontend overly complicated, open to any recommendations + + general: { + cancel: 'Cancel', // TODO: this is in other i18n files, move to general.ts? + label: 'Discussions', + mostRecentActivity: 'Most recent activity', + newTopics: '{{numNewTopics}} new discussion topics', + discussedTopics: '{{numDiscussedTopics}} discussions with replies', + fieldsMarkedRequired: + 'Fields marked with an asterisk () are required.', // TODO: this is in other i18n files, move to general.ts? + nonDiscussedTopics: '{{numDiscussedTopics}} discussions without replies', + readMore: 'Read more', // TODO: this is in other i18n files, move to general.ts? + repliesInDiscussion: '{{numReplies}} in this discussion', + reply: 'Reply', + saveDiscussion: 'Save discussion', + + startDiscussion: 'Start a new discussion', + + view: 'View', // TODO: this is in other i18n files, move to general.ts? + viewDiscussionBoard: 'View discussion board', + + alerts: { + noDiscussions: + 'There are no discussions yet. WHen a discussion topic is started, it will appear here.', + replyError: + 'There was an issue with adding your reply, please try again.', + replySuccess: 'Success! Your reply has been added.', + saveDiscussion: + 'WHen you save your discussion, the selected team(s) and individual(s) will be notified via email.', + startDiscussionError: + 'There was an issue with adding to the discussion board please try again.', + startDiscussionSuccess: + 'You have successfully added to the discussion board' + }, + + contribute: { + // description: + // 'Have a question or comment that you want to discuss internally with the Governance Admin Team or other Governance Review Board (GRB) members involved in this request? Start a discussion and you’ll be notified when they reply.', + description: + 'Have a question or comment that you want to discuss internally with the {{groupNames}} members involved in this request? Start a discussion and you’ll be notified when they reply.', + newTopicInstructions: 'Type your question or discussion topic', + replyInstructions: 'Type your reply', + taggingHelpText: + 'To tag an individual or team, type "@" and select the individual or group you wish to notify. You may begin typing the group name or individual’s name if you do not see it in the list. In this discussion board, you are only able to tag GRB reviewers or Governance Admin Team members.' + }, + + usageTips: { + label: 'Tips for using the discussion boards', + content: [ + 'Start a new discussion thread for each new topic', + // 'Use tags (@) any time you need input from a specific individual or group. Group tags will notify all members of that group. Avalailble group tags: @Governance Review Board, @Governance Admin Team, @Admin Lead', + 'Use tags (@) any time you need input from a specific individual or group. Group tags will notify all members of that group. Avalailble group tags {{groupNames}}', // TODO: wont display @ symbol? + 'Participating individuals will get an email notification when a new discussion is started, or when they are tagged in a discussion or reply' + ] + } + }, + + // Board Specific Translations + governanceReviewBoard: { + adminPanel: { + // description: + // 'Use the discussion boards below to discuss this project. The internal GRB discussion board is a space for the Governance Admin Team and GRB members to discuss privately; the project team will not be able to view discussions there.', + description: + 'Use the discussion boards below to discuss this project. The {{discussionBoardType}} is a space for the {{groupNames}} members to discuss privately; the project team will not be able to view discussions there.' + }, + internal: { + label: 'Internal GRB discussion board', // TODO: enum translation? + // description: + // 'Use this discussion board to ask questions or have dicussions with the Governance Admin Team and other Governance Review Board (GRB) members. The conversations here are not visible to the Project team.' + description: + 'Use this discussion board to ask questions or have dicussions with the {{groupNames}} members. The conversations here are not visible to the Project team.' + } + } +}; + +export default discussions; From 92c3f05f63702ff2b1c3e77dd56dcdeabead1c75 Mon Sep 17 00:00:00 2001 From: Ashley Terstriep <60187543+aterstriep@users.noreply.github.com> Date: Thu, 14 Nov 2024 11:49:48 -0600 Subject: [PATCH 03/22] [NOREF] Discussions translation fixes (#2888) * Add discussions to translation files index * Plural i18n translations * Typos --- src/i18n/en-US/discussions.ts | 20 ++++++++++++-------- src/i18n/en-US/index.ts | 2 ++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/i18n/en-US/discussions.ts b/src/i18n/en-US/discussions.ts index 4e7938daea..45ee1ed898 100644 --- a/src/i18n/en-US/discussions.ts +++ b/src/i18n/en-US/discussions.ts @@ -8,13 +8,17 @@ const discussions = { cancel: 'Cancel', // TODO: this is in other i18n files, move to general.ts? label: 'Discussions', mostRecentActivity: 'Most recent activity', - newTopics: '{{numNewTopics}} new discussion topics', - discussedTopics: '{{numDiscussedTopics}} discussions with replies', + newTopics: '{{count}} new discussion topic', + newTopics_plural: '{{count}} new discussion topics', + discussedTopics: '{{count}} discussion with replies', + discussedTopics_plural: '{{count}} discussions with replies', fieldsMarkedRequired: 'Fields marked with an asterisk () are required.', // TODO: this is in other i18n files, move to general.ts? - nonDiscussedTopics: '{{numDiscussedTopics}} discussions without replies', + nonDiscussedTopics: '{{count}} discussion without replies', + nonDiscussedTopics_plural: '{{count}} discussions without replies', readMore: 'Read more', // TODO: this is in other i18n files, move to general.ts? - repliesInDiscussion: '{{numReplies}} in this discussion', + repliesInDiscussion: '{{count}} reply in this discussion', + repliesInDiscussion_plural: '{{count}} replies in this discussion', reply: 'Reply', saveDiscussion: 'Save discussion', @@ -25,16 +29,16 @@ const discussions = { alerts: { noDiscussions: - 'There are no discussions yet. WHen a discussion topic is started, it will appear here.', + 'There are no discussions yet. When a discussion topic is started, it will appear here.', replyError: 'There was an issue with adding your reply, please try again.', replySuccess: 'Success! Your reply has been added.', saveDiscussion: - 'WHen you save your discussion, the selected team(s) and individual(s) will be notified via email.', + 'When you save your discussion, the selected team(s) and individual(s) will be notified via email.', startDiscussionError: - 'There was an issue with adding to the discussion board please try again.', + 'There was an issue with adding to the discussion board, please try again.', startDiscussionSuccess: - 'You have successfully added to the discussion board' + 'You have successfully added to the discussion board.' }, contribute: { diff --git a/src/i18n/en-US/index.ts b/src/i18n/en-US/index.ts index 4e7ca0eba0..f8e08c209d 100644 --- a/src/i18n/en-US/index.ts +++ b/src/i18n/en-US/index.ts @@ -12,6 +12,7 @@ import admin from './admin'; import auth from './auth'; import businessCase from './businessCase'; import cookies from './cookies'; +import discussions from './discussions'; import error from './error'; import externalLinkModal from './externalLinkModal'; import footer from './footer'; @@ -39,6 +40,7 @@ const enUS = { auth, businessCase, cookies, + discussions, error, externalLinkModal, footer, From dcd703ed276ef05510c1837af95234df8fe57753 Mon Sep 17 00:00:00 2001 From: Lee Warrick <32332479+mynar7@users.noreply.github.com> Date: Fri, 15 Nov 2024 11:52:47 -0500 Subject: [PATCH 04/22] [EASI-4635] Initial discussions API schema and migration (#2887) * initial discussions api schema and migration * rename migration * add new table to truncate lists * formatting * update postman, remove payload types, add constraint --- EASI.postman_collection.json | 109 +- ...V195__Add_grb_review_discussions_table.sql | 47 + pkg/graph/generated/generated.go | 1695 ++++++++++++++++- pkg/graph/schema.graphql | 35 + pkg/graph/schema.resolvers.go | 69 + pkg/models/models_gen.go | 15 + pkg/models/system_intake_grb_discussions.go | 27 + pkg/storage/truncate.go | 1 + scripts/dev | 3 +- src/gql/gen/graphql.ts | 45 + 10 files changed, 1953 insertions(+), 93 deletions(-) create mode 100644 migrations/V195__Add_grb_review_discussions_table.sql create mode 100644 pkg/models/system_intake_grb_discussions.go diff --git a/EASI.postman_collection.json b/EASI.postman_collection.json index 4743cc5f63..b8c664d899 100644 --- a/EASI.postman_collection.json +++ b/EASI.postman_collection.json @@ -1000,7 +1000,7 @@ "listen": "test", "script": { "exec": [ - "pm.collectionVariables.set(\"SystemIntakeGRBReviewerID\", pm.response.json().data.createSystemIntakeGRBReviewer.id)" + "pm.collectionVariables.set(\"SystemIntakeGRBReviewerID\", pm.response.json().data.createSystemIntakeGRBReviewers.reviewers[0].id)" ], "type": "text/javascript", "packages": {} @@ -1013,8 +1013,8 @@ "body": { "mode": "graphql", "graphql": { - "query": "mutation createS($input: CreateSystemIntakeGRBReviewerInput!) {\n createSystemIntakeGRBReviewer(input: $input) {\n id\n userAccount {\n id\n }\n grbRole\n votingRole\n systemIntakeID\n createdBy\n createdAt\n modifiedBy\n modifiedAt\n }\n}", - "variables": "{\r\n \"input\": {\r\n \"euaUserId\": \"ABCD\",\r\n \"systemIntakeID\": \"8edb237e-ad48-49b2-91cf-8534362bc6cf\",\r\n \"votingRole\": \"NON_VOTING\",\r\n \"grbRole\": \"CMCS_REP\"\r\n }\r\n}" + "query": "mutation createSystemIntakeGRBReviewer($input: CreateSystemIntakeGRBReviewersInput!) {\n createSystemIntakeGRBReviewers(input: $input) {\n reviewers {\n id\n userAccount {\n id\n }\n grbRole\n votingRole\n systemIntakeID\n createdBy\n createdAt\n modifiedBy\n modifiedAt\n }\n \n }\n}", + "variables": "{\r\n \"input\": {\r\n \"systemIntakeID\": \"8edb237e-ad48-49b2-91cf-8534362bc6cf\",\r\n \"reviewers\": {\r\n \"euaUserId\": \"ABCD\",\r\n \"votingRole\": \"NON_VOTING\",\r\n \"grbRole\": \"CMCS_REP\"\r\n }\r\n }\r\n}" } }, "url": { @@ -1124,6 +1124,105 @@ } }, "response": [] + }, + { + "name": "Get GRB Discussions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "query getSystemIntakeGRBReviewers($systemIntakeID: UUID!) {\n systemIntake(id: $systemIntakeID) {\n id\n grbDiscussions {\n initialPost {\n content\n createdByUserAccount {\n givenName\n familyName\n }\n }\n replies {\n content\n createdByUserAccount {\n givenName\n familyName\n }\n }\n }\n }\n}", + "variables": "{\r\n \"systemIntakeID\": \"{{systemIntakeID}}\"\r\n}" + } + }, + "url": { + "raw": "{{url}}", + "host": [ + "{{url}}" + ] + } + }, + "response": [] + }, + { + "name": "Create GRB Discussion", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "mutation createSystemIntakeGRBDiscussion($input: createSystemIntakeGRBDiscussionPostInput!) {\n createSystemIntakeGRBDiscussionPost(input: $input) {\n id\n content\n grbRole\n votingRole\n systemIntakeID\n createdByUserAccount {\n id\n username\n }\n }\n}", + "variables": "{\r\n \"input\": {\r\n \"systemIntakeID\": \"8edb237e-ad48-49b2-91cf-8534362bc6cf\",\r\n \"content\": \"

banana apple carburetor

\"\r\n }\r\n}" + } + }, + "url": { + "raw": "{{url}}", + "host": [ + "{{url}}" + ] + } + }, + "response": [] + }, + { + "name": "Add GRB Discussion Reply", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "mutation createSystemIntakeGRBDiscussionReply($input: createSystemIntakeGRBDiscussionReplyInput!) {\n createSystemIntakeGRBDiscussionReply(input: $input) {\n id\n content\n grbRole\n votingRole\n systemIntakeID\n createdByUserAccount {\n id\n username\n }\n }\n}", + "variables": "{\r\n \"input\": {\r\n \"initialPostID\": \"00000000-0000-0000-0000-000000000000\",\r\n \"content\": \"

monkey kiwi phonebook

\"\r\n }\r\n}" + } + }, + "url": { + "raw": "{{url}}", + "host": [ + "{{url}}" + ] + } + }, + "response": [] } ] }, @@ -4073,6 +4172,10 @@ { "key": "CedarAtoIds", "value": "" + }, + { + "key": "SystemIntakeGRBReviewerID", + "value": "" } ] } diff --git a/migrations/V195__Add_grb_review_discussions_table.sql b/migrations/V195__Add_grb_review_discussions_table.sql new file mode 100644 index 0000000000..409f657ced --- /dev/null +++ b/migrations/V195__Add_grb_review_discussions_table.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS system_intake_internal_grb_review_discussion_posts ( + id UUID PRIMARY KEY NOT NULL, + content TEXT NOT NULL, + voting_role grb_reviewer_voting_role_type, + grb_role grb_reviewer_role_type, + system_intake_id UUID NOT NULL + REFERENCES system_intakes(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + reply_to_id UUID + REFERENCES system_intake_internal_grb_review_discussion_posts (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + created_by UUID NOT NULL REFERENCES user_account(id), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_by UUID REFERENCES user_account(id), + modified_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT is_admin_or_reviewer CHECK ( + (voting_role IS NULL AND grb_role IS NULL) OR + (voting_role IS NOT NULL AND grb_role IS NOT NULL) + ) +); + +CREATE OR REPLACE FUNCTION prevent_nested_replies_fn() RETURNS TRIGGER AS $$ +DECLARE + parent_reply_to_id UUID; +BEGIN + -- If reply_to_id is not NULL, check if it points to a top-level post + IF NEW.reply_to_id IS NOT NULL THEN + -- Fetch the reply_to_id of the parent post + SELECT reply_to_id INTO parent_reply_to_id + FROM system_intake_internal_grb_review_discussion_posts + WHERE id = NEW.reply_to_id; + + -- If parent_reply_to_id is NOT NULL, the parent post is a reply, so raise an error + IF parent_reply_to_id IS NOT NULL THEN + RAISE EXCEPTION 'Replies can only be made to top-level posts'; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER prevent_nested_replies_trigger +BEFORE INSERT OR UPDATE ON system_intake_internal_grb_review_discussion_posts +FOR EACH ROW EXECUTE FUNCTION prevent_nested_replies_fn(); diff --git a/pkg/graph/generated/generated.go b/pkg/graph/generated/generated.go index 7c3503bd44..aa39e3a84c 100644 --- a/pkg/graph/generated/generated.go +++ b/pkg/graph/generated/generated.go @@ -54,6 +54,7 @@ type ResolverRoot interface { Query() QueryResolver SystemIntake() SystemIntakeResolver SystemIntakeDocument() SystemIntakeDocumentResolver + SystemIntakeGRBReviewDiscussionPost() SystemIntakeGRBReviewDiscussionPostResolver SystemIntakeGRBReviewer() SystemIntakeGRBReviewerResolver SystemIntakeNote() SystemIntakeNoteResolver TRBAdminNote() TRBAdminNoteResolver @@ -563,6 +564,8 @@ type ComplexityRoot struct { CreateSystemIntakeActionUpdateLcid func(childComplexity int, input models.SystemIntakeUpdateLCIDInput) int CreateSystemIntakeContact func(childComplexity int, input models.CreateSystemIntakeContactInput) int CreateSystemIntakeDocument func(childComplexity int, input models.CreateSystemIntakeDocumentInput) int + CreateSystemIntakeGRBDiscussionPost func(childComplexity int, input models.CreateSystemIntakeGRBDiscussionPostInput) int + CreateSystemIntakeGRBDiscussionReply func(childComplexity int, input models.CreateSystemIntakeGRBDiscussionReplyInput) int CreateSystemIntakeGRBReviewers func(childComplexity int, input models.CreateSystemIntakeGRBReviewersInput) int CreateSystemIntakeNote func(childComplexity int, input models.CreateSystemIntakeNoteInput) int CreateTRBAdminNoteConsultSession func(childComplexity int, input models.CreateTRBAdminNoteConsultSessionInput) int @@ -696,6 +699,7 @@ type ComplexityRoot struct { GRTMeetingState func(childComplexity int) int GovernanceRequestFeedbacks func(childComplexity int) int GovernanceTeams func(childComplexity int) int + GrbDiscussions func(childComplexity int) int GrbReviewers func(childComplexity int) int GrtReviewEmailBody func(childComplexity int) int HasUIChanges func(childComplexity int) int @@ -839,6 +843,23 @@ type ComplexityRoot struct { Source func(childComplexity int) int } + SystemIntakeGRBReviewDiscussion struct { + InitialPost func(childComplexity int) int + Replies func(childComplexity int) int + } + + SystemIntakeGRBReviewDiscussionPost struct { + Content func(childComplexity int) int + CreatedAt func(childComplexity int) int + CreatedByUserAccount func(childComplexity int) int + GrbRole func(childComplexity int) int + ID func(childComplexity int) int + ModifiedAt func(childComplexity int) int + ModifiedByUserAccount func(childComplexity int) int + SystemIntakeID func(childComplexity int) int + VotingRole func(childComplexity int) int + } + SystemIntakeGRBReviewer struct { CreatedAt func(childComplexity int) int CreatedBy func(childComplexity int) int @@ -1213,6 +1234,8 @@ type MutationResolver interface { CreateSystemIntakeGRBReviewers(ctx context.Context, input models.CreateSystemIntakeGRBReviewersInput) (*models.CreateSystemIntakeGRBReviewersPayload, error) UpdateSystemIntakeGRBReviewer(ctx context.Context, input models.UpdateSystemIntakeGRBReviewerInput) (*models.SystemIntakeGRBReviewer, error) DeleteSystemIntakeGRBReviewer(ctx context.Context, input models.DeleteSystemIntakeGRBReviewerInput) (uuid.UUID, error) + CreateSystemIntakeGRBDiscussionPost(ctx context.Context, input models.CreateSystemIntakeGRBDiscussionPostInput) (*models.SystemIntakeGRBReviewDiscussionPost, error) + CreateSystemIntakeGRBDiscussionReply(ctx context.Context, input models.CreateSystemIntakeGRBDiscussionReplyInput) (*models.SystemIntakeGRBReviewDiscussionPost, error) UpdateSystemIntakeLinkedCedarSystem(ctx context.Context, input models.UpdateSystemIntakeLinkedCedarSystemInput) (*models.UpdateSystemIntakePayload, error) ArchiveSystemIntake(ctx context.Context, id uuid.UUID) (*models.SystemIntake, error) SendFeedbackEmail(ctx context.Context, input models.SendFeedbackEmailInput) (*string, error) @@ -1345,6 +1368,7 @@ type SystemIntakeResolver interface { ContractNumbers(ctx context.Context, obj *models.SystemIntake) ([]*models.SystemIntakeContractNumber, error) RelatedIntakes(ctx context.Context, obj *models.SystemIntake) ([]*models.SystemIntake, error) RelatedTRBRequests(ctx context.Context, obj *models.SystemIntake) ([]*models.TRBRequest, error) + GrbDiscussions(ctx context.Context, obj *models.SystemIntake) ([]*models.SystemIntakeGRBReviewDiscussion, error) } type SystemIntakeDocumentResolver interface { DocumentType(ctx context.Context, obj *models.SystemIntakeDocument) (*models.SystemIntakeDocumentType, error) @@ -1356,6 +1380,10 @@ type SystemIntakeDocumentResolver interface { CanDelete(ctx context.Context, obj *models.SystemIntakeDocument) (bool, error) CanView(ctx context.Context, obj *models.SystemIntakeDocument) (bool, error) } +type SystemIntakeGRBReviewDiscussionPostResolver interface { + VotingRole(ctx context.Context, obj *models.SystemIntakeGRBReviewDiscussionPost) (*models.SystemIntakeGRBReviewerVotingRole, error) + GrbRole(ctx context.Context, obj *models.SystemIntakeGRBReviewDiscussionPost) (*models.SystemIntakeGRBReviewerRole, error) +} type SystemIntakeGRBReviewerResolver interface { VotingRole(ctx context.Context, obj *models.SystemIntakeGRBReviewer) (models.SystemIntakeGRBReviewerVotingRole, error) GrbRole(ctx context.Context, obj *models.SystemIntakeGRBReviewer) (models.SystemIntakeGRBReviewerRole, error) @@ -4124,6 +4152,30 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.CreateSystemIntakeDocument(childComplexity, args["input"].(models.CreateSystemIntakeDocumentInput)), true + case "Mutation.createSystemIntakeGRBDiscussionPost": + if e.complexity.Mutation.CreateSystemIntakeGRBDiscussionPost == nil { + break + } + + args, err := ec.field_Mutation_createSystemIntakeGRBDiscussionPost_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.CreateSystemIntakeGRBDiscussionPost(childComplexity, args["input"].(models.CreateSystemIntakeGRBDiscussionPostInput)), true + + case "Mutation.createSystemIntakeGRBDiscussionReply": + if e.complexity.Mutation.CreateSystemIntakeGRBDiscussionReply == nil { + break + } + + args, err := ec.field_Mutation_createSystemIntakeGRBDiscussionReply_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.CreateSystemIntakeGRBDiscussionReply(childComplexity, args["input"].(models.CreateSystemIntakeGRBDiscussionReplyInput)), true + case "Mutation.createSystemIntakeGRBReviewers": if e.complexity.Mutation.CreateSystemIntakeGRBReviewers == nil { break @@ -5428,6 +5480,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.SystemIntake.GovernanceTeams(childComplexity), true + case "SystemIntake.grbDiscussions": + if e.complexity.SystemIntake.GrbDiscussions == nil { + break + } + + return e.complexity.SystemIntake.GrbDiscussions(childComplexity), true + case "SystemIntake.grbReviewers": if e.complexity.SystemIntake.GrbReviewers == nil { break @@ -6142,6 +6201,83 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.SystemIntakeFundingSource.Source(childComplexity), true + case "SystemIntakeGRBReviewDiscussion.initialPost": + if e.complexity.SystemIntakeGRBReviewDiscussion.InitialPost == nil { + break + } + + return e.complexity.SystemIntakeGRBReviewDiscussion.InitialPost(childComplexity), true + + case "SystemIntakeGRBReviewDiscussion.replies": + if e.complexity.SystemIntakeGRBReviewDiscussion.Replies == nil { + break + } + + return e.complexity.SystemIntakeGRBReviewDiscussion.Replies(childComplexity), true + + case "SystemIntakeGRBReviewDiscussionPost.content": + if e.complexity.SystemIntakeGRBReviewDiscussionPost.Content == nil { + break + } + + return e.complexity.SystemIntakeGRBReviewDiscussionPost.Content(childComplexity), true + + case "SystemIntakeGRBReviewDiscussionPost.createdAt": + if e.complexity.SystemIntakeGRBReviewDiscussionPost.CreatedAt == nil { + break + } + + return e.complexity.SystemIntakeGRBReviewDiscussionPost.CreatedAt(childComplexity), true + + case "SystemIntakeGRBReviewDiscussionPost.createdByUserAccount": + if e.complexity.SystemIntakeGRBReviewDiscussionPost.CreatedByUserAccount == nil { + break + } + + return e.complexity.SystemIntakeGRBReviewDiscussionPost.CreatedByUserAccount(childComplexity), true + + case "SystemIntakeGRBReviewDiscussionPost.grbRole": + if e.complexity.SystemIntakeGRBReviewDiscussionPost.GrbRole == nil { + break + } + + return e.complexity.SystemIntakeGRBReviewDiscussionPost.GrbRole(childComplexity), true + + case "SystemIntakeGRBReviewDiscussionPost.id": + if e.complexity.SystemIntakeGRBReviewDiscussionPost.ID == nil { + break + } + + return e.complexity.SystemIntakeGRBReviewDiscussionPost.ID(childComplexity), true + + case "SystemIntakeGRBReviewDiscussionPost.modifiedAt": + if e.complexity.SystemIntakeGRBReviewDiscussionPost.ModifiedAt == nil { + break + } + + return e.complexity.SystemIntakeGRBReviewDiscussionPost.ModifiedAt(childComplexity), true + + case "SystemIntakeGRBReviewDiscussionPost.modifiedByUserAccount": + if e.complexity.SystemIntakeGRBReviewDiscussionPost.ModifiedByUserAccount == nil { + break + } + + return e.complexity.SystemIntakeGRBReviewDiscussionPost.ModifiedByUserAccount(childComplexity), true + + case "SystemIntakeGRBReviewDiscussionPost.systemIntakeID": + if e.complexity.SystemIntakeGRBReviewDiscussionPost.SystemIntakeID == nil { + break + } + + return e.complexity.SystemIntakeGRBReviewDiscussionPost.SystemIntakeID(childComplexity), true + + case "SystemIntakeGRBReviewDiscussionPost.votingRole": + if e.complexity.SystemIntakeGRBReviewDiscussionPost.VotingRole == nil { + break + } + + return e.complexity.SystemIntakeGRBReviewDiscussionPost.VotingRole(childComplexity), true + case "SystemIntakeGRBReviewer.createdAt": if e.complexity.SystemIntakeGRBReviewer.CreatedAt == nil { break @@ -7705,6 +7841,8 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputUpdateTRBRequestFormInput, ec.unmarshalInputUpdateTRBRequestFundingSourcesInput, ec.unmarshalInputUpdateTRBRequestTRBLeadInput, + ec.unmarshalInputcreateSystemIntakeGRBDiscussionPostInput, + ec.unmarshalInputcreateSystemIntakeGRBDiscussionReplyInput, ) first := true @@ -8595,6 +8733,10 @@ type SystemIntake { TRB Requests that share a CEDAR System or Contract Number """ relatedTRBRequests: [TRBRequest!]! + """ + GRB Review Discussion Posts/Threads + """ + grbDiscussions: [SystemIntakeGRBReviewDiscussion!]! } type SystemIntakeContractNumber { @@ -9030,6 +9172,34 @@ enum SystemIntakeGRBReviewerVotingRole { NON_VOTING } +type SystemIntakeGRBReviewDiscussionPost { + id: UUID! + content: HTML! + votingRole: SystemIntakeGRBReviewerVotingRole + grbRole: SystemIntakeGRBReviewerRole + systemIntakeID: UUID! + createdByUserAccount: UserAccount! + createdAt: Time! + modifiedByUserAccount: UserAccount + modifiedAt: Time +} + +type SystemIntakeGRBReviewDiscussion { + initialPost: SystemIntakeGRBReviewDiscussionPost! + replies: [SystemIntakeGRBReviewDiscussionPost!]! +} + + +input createSystemIntakeGRBDiscussionPostInput { + systemIntakeID: UUID! + content: HTML! +} + +input createSystemIntakeGRBDiscussionReplyInput { + initialPostID: UUID! + content: HTML! +} + """ Input data used to update the admin lead assigned to a system IT governance request @@ -10323,6 +10493,9 @@ type Mutation { updateSystemIntakeGRBReviewer(input: UpdateSystemIntakeGRBReviewerInput!): SystemIntakeGRBReviewer! deleteSystemIntakeGRBReviewer(input: DeleteSystemIntakeGRBReviewerInput!): UUID! + createSystemIntakeGRBDiscussionPost(input: createSystemIntakeGRBDiscussionPostInput!): SystemIntakeGRBReviewDiscussionPost + createSystemIntakeGRBDiscussionReply(input: createSystemIntakeGRBDiscussionReplyInput!): SystemIntakeGRBReviewDiscussionPost + updateSystemIntakeLinkedCedarSystem(input: UpdateSystemIntakeLinkedCedarSystemInput!): UpdateSystemIntakePayload archiveSystemIntake(id: UUID!): SystemIntake! @@ -11518,6 +11691,70 @@ func (ec *executionContext) field_Mutation_createSystemIntakeDocument_argsInput( return zeroVal, nil } +func (ec *executionContext) field_Mutation_createSystemIntakeGRBDiscussionPost_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + arg0, err := ec.field_Mutation_createSystemIntakeGRBDiscussionPost_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_createSystemIntakeGRBDiscussionPost_argsInput( + ctx context.Context, + rawArgs map[string]interface{}, +) (models.CreateSystemIntakeGRBDiscussionPostInput, error) { + // We won't call the directive if the argument is null. + // Set call_argument_directives_with_null to true to call directives + // even if the argument is null. + _, ok := rawArgs["input"] + if !ok { + var zeroVal models.CreateSystemIntakeGRBDiscussionPostInput + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNcreateSystemIntakeGRBDiscussionPostInput2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐCreateSystemIntakeGRBDiscussionPostInput(ctx, tmp) + } + + var zeroVal models.CreateSystemIntakeGRBDiscussionPostInput + return zeroVal, nil +} + +func (ec *executionContext) field_Mutation_createSystemIntakeGRBDiscussionReply_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + arg0, err := ec.field_Mutation_createSystemIntakeGRBDiscussionReply_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_createSystemIntakeGRBDiscussionReply_argsInput( + ctx context.Context, + rawArgs map[string]interface{}, +) (models.CreateSystemIntakeGRBDiscussionReplyInput, error) { + // We won't call the directive if the argument is null. + // Set call_argument_directives_with_null to true to call directives + // even if the argument is null. + _, ok := rawArgs["input"] + if !ok { + var zeroVal models.CreateSystemIntakeGRBDiscussionReplyInput + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNcreateSystemIntakeGRBDiscussionReplyInput2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐCreateSystemIntakeGRBDiscussionReplyInput(ctx, tmp) + } + + var zeroVal models.CreateSystemIntakeGRBDiscussionReplyInput + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_createSystemIntakeGRBReviewers_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -15797,6 +16034,8 @@ func (ec *executionContext) fieldContext_BusinessCase_systemIntake(_ context.Con return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -24687,6 +24926,8 @@ func (ec *executionContext) fieldContext_CedarSystem_linkedSystemIntakes(ctx con return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -31587,6 +31828,8 @@ func (ec *executionContext) fieldContext_Mutation_createSystemIntake(ctx context return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -31827,6 +32070,8 @@ func (ec *executionContext) fieldContext_Mutation_updateSystemIntakeRequestType( return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -32997,6 +33242,150 @@ func (ec *executionContext) fieldContext_Mutation_deleteSystemIntakeGRBReviewer( return fc, nil } +func (ec *executionContext) _Mutation_createSystemIntakeGRBDiscussionPost(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_createSystemIntakeGRBDiscussionPost(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().CreateSystemIntakeGRBDiscussionPost(rctx, fc.Args["input"].(models.CreateSystemIntakeGRBDiscussionPostInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*models.SystemIntakeGRBReviewDiscussionPost) + fc.Result = res + return ec.marshalOSystemIntakeGRBReviewDiscussionPost2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPost(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_createSystemIntakeGRBDiscussionPost(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_id(ctx, field) + case "content": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_content(ctx, field) + case "votingRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_votingRole(ctx, field) + case "grbRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_grbRole(ctx, field) + case "systemIntakeID": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_systemIntakeID(ctx, field) + case "createdByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(ctx, field) + case "createdAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdAt(ctx, field) + case "modifiedByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(ctx, field) + case "modifiedAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SystemIntakeGRBReviewDiscussionPost", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_createSystemIntakeGRBDiscussionPost_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_createSystemIntakeGRBDiscussionReply(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_createSystemIntakeGRBDiscussionReply(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().CreateSystemIntakeGRBDiscussionReply(rctx, fc.Args["input"].(models.CreateSystemIntakeGRBDiscussionReplyInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*models.SystemIntakeGRBReviewDiscussionPost) + fc.Result = res + return ec.marshalOSystemIntakeGRBReviewDiscussionPost2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPost(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_createSystemIntakeGRBDiscussionReply(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_id(ctx, field) + case "content": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_content(ctx, field) + case "votingRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_votingRole(ctx, field) + case "grbRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_grbRole(ctx, field) + case "systemIntakeID": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_systemIntakeID(ctx, field) + case "createdByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(ctx, field) + case "createdAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdAt(ctx, field) + case "modifiedByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(ctx, field) + case "modifiedAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SystemIntakeGRBReviewDiscussionPost", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_createSystemIntakeGRBDiscussionReply_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_updateSystemIntakeLinkedCedarSystem(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_updateSystemIntakeLinkedCedarSystem(ctx, field) if err != nil { @@ -33250,6 +33639,8 @@ func (ec *executionContext) fieldContext_Mutation_archiveSystemIntake(ctx contex return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -37462,6 +37853,8 @@ func (ec *executionContext) fieldContext_Query_systemIntake(ctx context.Context, return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -37675,6 +38068,8 @@ func (ec *executionContext) fieldContext_Query_systemIntakes(ctx context.Context return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -37888,6 +38283,8 @@ func (ec *executionContext) fieldContext_Query_mySystemIntakes(_ context.Context return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -38090,6 +38487,8 @@ func (ec *executionContext) fieldContext_Query_systemIntakesWithReviewRequested( return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -38292,6 +38691,8 @@ func (ec *executionContext) fieldContext_Query_systemIntakesWithLcids(_ context. return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -44285,6 +44686,8 @@ func (ec *executionContext) fieldContext_SystemIntake_relatedIntakes(_ context.C return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -44400,6 +44803,56 @@ func (ec *executionContext) fieldContext_SystemIntake_relatedTRBRequests(_ conte return fc, nil } +func (ec *executionContext) _SystemIntake_grbDiscussions(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntake) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.SystemIntake().GrbDiscussions(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]*models.SystemIntakeGRBReviewDiscussion) + fc.Result = res + return ec.marshalNSystemIntakeGRBReviewDiscussion2ᚕᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntake_grbDiscussions(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntake", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "initialPost": + return ec.fieldContext_SystemIntakeGRBReviewDiscussion_initialPost(ctx, field) + case "replies": + return ec.fieldContext_SystemIntakeGRBReviewDiscussion_replies(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SystemIntakeGRBReviewDiscussion", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _SystemIntakeAction_id(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeAction) (ret graphql.Marshaler) { fc, err := ec.fieldContext_SystemIntakeAction_id(ctx, field) if err != nil { @@ -44639,6 +45092,8 @@ func (ec *executionContext) fieldContext_SystemIntakeAction_systemIntake(_ conte return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -47150,6 +47605,558 @@ func (ec *executionContext) fieldContext_SystemIntakeFundingSource_source(_ cont return fc, nil } +func (ec *executionContext) _SystemIntakeGRBReviewDiscussion_initialPost(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussion) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussion_initialPost(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.InitialPost, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*models.SystemIntakeGRBReviewDiscussionPost) + fc.Result = res + return ec.marshalNSystemIntakeGRBReviewDiscussionPost2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPost(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussion_initialPost(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussion", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_id(ctx, field) + case "content": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_content(ctx, field) + case "votingRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_votingRole(ctx, field) + case "grbRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_grbRole(ctx, field) + case "systemIntakeID": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_systemIntakeID(ctx, field) + case "createdByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(ctx, field) + case "createdAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdAt(ctx, field) + case "modifiedByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(ctx, field) + case "modifiedAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SystemIntakeGRBReviewDiscussionPost", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussion_replies(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussion) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussion_replies(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Replies, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]*models.SystemIntakeGRBReviewDiscussionPost) + fc.Result = res + return ec.marshalNSystemIntakeGRBReviewDiscussionPost2ᚕᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPostᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussion_replies(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussion", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_id(ctx, field) + case "content": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_content(ctx, field) + case "votingRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_votingRole(ctx, field) + case "grbRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_grbRole(ctx, field) + case "systemIntakeID": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_systemIntakeID(ctx, field) + case "createdByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(ctx, field) + case "createdAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdAt(ctx, field) + case "modifiedByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(ctx, field) + case "modifiedAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SystemIntakeGRBReviewDiscussionPost", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_id(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_id(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(uuid.UUID) + fc.Result = res + return ec.marshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type UUID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_content(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_content(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Content, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(models.HTML) + fc.Result = res + return ec.marshalNHTML2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐHTML(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_content(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type HTML does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_votingRole(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_votingRole(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.SystemIntakeGRBReviewDiscussionPost().VotingRole(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*models.SystemIntakeGRBReviewerVotingRole) + fc.Result = res + return ec.marshalOSystemIntakeGRBReviewerVotingRole2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewerVotingRole(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_votingRole(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type SystemIntakeGRBReviewerVotingRole does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_grbRole(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_grbRole(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.SystemIntakeGRBReviewDiscussionPost().GrbRole(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*models.SystemIntakeGRBReviewerRole) + fc.Result = res + return ec.marshalOSystemIntakeGRBReviewerRole2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewerRole(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_grbRole(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type SystemIntakeGRBReviewerRole does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_systemIntakeID(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_systemIntakeID(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.SystemIntakeID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(uuid.UUID) + fc.Result = res + return ec.marshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_systemIntakeID(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type UUID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.CreatedByUserAccount(ctx) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*authentication.UserAccount) + fc.Result = res + return ec.marshalNUserAccount2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋauthenticationᚐUserAccount(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_UserAccount_id(ctx, field) + case "username": + return ec.fieldContext_UserAccount_username(ctx, field) + case "commonName": + return ec.fieldContext_UserAccount_commonName(ctx, field) + case "locale": + return ec.fieldContext_UserAccount_locale(ctx, field) + case "email": + return ec.fieldContext_UserAccount_email(ctx, field) + case "givenName": + return ec.fieldContext_UserAccount_givenName(ctx, field) + case "familyName": + return ec.fieldContext_UserAccount_familyName(ctx, field) + case "zoneInfo": + return ec.fieldContext_UserAccount_zoneInfo(ctx, field) + case "hasLoggedIn": + return ec.fieldContext_UserAccount_hasLoggedIn(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type UserAccount", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_createdAt(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdAt(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.CreatedAt, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(time.Time) + fc.Result = res + return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Time does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ModifiedByUserAccount(ctx) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*authentication.UserAccount) + fc.Result = res + return ec.marshalOUserAccount2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋauthenticationᚐUserAccount(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_UserAccount_id(ctx, field) + case "username": + return ec.fieldContext_UserAccount_username(ctx, field) + case "commonName": + return ec.fieldContext_UserAccount_commonName(ctx, field) + case "locale": + return ec.fieldContext_UserAccount_locale(ctx, field) + case "email": + return ec.fieldContext_UserAccount_email(ctx, field) + case "givenName": + return ec.fieldContext_UserAccount_givenName(ctx, field) + case "familyName": + return ec.fieldContext_UserAccount_familyName(ctx, field) + case "zoneInfo": + return ec.fieldContext_UserAccount_zoneInfo(ctx, field) + case "hasLoggedIn": + return ec.fieldContext_UserAccount_hasLoggedIn(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type UserAccount", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_modifiedAt(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedAt(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ModifiedAt, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*time.Time) + fc.Result = res + return ec.marshalOTime2ᚖtimeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Time does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _SystemIntakeGRBReviewer_id(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewer) (ret graphql.Marshaler) { fc, err := ec.fieldContext_SystemIntakeGRBReviewer_id(ctx, field) if err != nil { @@ -52843,6 +53850,8 @@ func (ec *executionContext) fieldContext_TRBRequest_relatedIntakes(_ context.Con return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -55710,6 +56719,8 @@ func (ec *executionContext) fieldContext_TRBRequestForm_systemIntakes(_ context. return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -56466,6 +57477,8 @@ func (ec *executionContext) fieldContext_UpdateSystemIntakePayload_systemIntake( return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -62780,6 +63793,74 @@ func (ec *executionContext) unmarshalInputUpdateTRBRequestTRBLeadInput(ctx conte return it, nil } +func (ec *executionContext) unmarshalInputcreateSystemIntakeGRBDiscussionPostInput(ctx context.Context, obj interface{}) (models.CreateSystemIntakeGRBDiscussionPostInput, error) { + var it models.CreateSystemIntakeGRBDiscussionPostInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"systemIntakeID", "content"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "systemIntakeID": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("systemIntakeID")) + data, err := ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + if err != nil { + return it, err + } + it.SystemIntakeID = data + case "content": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("content")) + data, err := ec.unmarshalNHTML2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐHTML(ctx, v) + if err != nil { + return it, err + } + it.Content = data + } + } + + return it, nil +} + +func (ec *executionContext) unmarshalInputcreateSystemIntakeGRBDiscussionReplyInput(ctx context.Context, obj interface{}) (models.CreateSystemIntakeGRBDiscussionReplyInput, error) { + var it models.CreateSystemIntakeGRBDiscussionReplyInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"initialPostID", "content"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "initialPostID": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("initialPostID")) + data, err := ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + if err != nil { + return it, err + } + it.InitialPostID = data + case "content": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("content")) + data, err := ec.unmarshalNHTML2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐHTML(ctx, v) + if err != nil { + return it, err + } + it.Content = data + } + } + + return it, nil +} + // endregion **************************** input.gotpl ***************************** // region ************************** interface.gotpl *************************** @@ -66015,6 +67096,14 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "createSystemIntakeGRBDiscussionPost": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_createSystemIntakeGRBDiscussionPost(ctx, field) + }) + case "createSystemIntakeGRBDiscussionReply": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_createSystemIntakeGRBDiscussionReply(ctx, field) + }) case "updateSystemIntakeLinkedCedarSystem": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_updateSystemIntakeLinkedCedarSystem(ctx, field) @@ -68402,6 +69491,42 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "grbDiscussions": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._SystemIntake_grbDiscussions(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) default: panic("unknown field " + strconv.Quote(field.Name)) @@ -69099,19 +70224,298 @@ func (ec *executionContext) _SystemIntakeDocument(ctx context.Context, sel ast.S } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "canDelete": + case "canDelete": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._SystemIntakeDocument_canDelete(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "canView": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._SystemIntakeDocument_canView(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "systemIntakeId": + out.Values[i] = ec._SystemIntakeDocument_systemIntakeId(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var systemIntakeDocumentTypeImplementors = []string{"SystemIntakeDocumentType"} + +func (ec *executionContext) _SystemIntakeDocumentType(ctx context.Context, sel ast.SelectionSet, obj *models.SystemIntakeDocumentType) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, systemIntakeDocumentTypeImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("SystemIntakeDocumentType") + case "commonType": + out.Values[i] = ec._SystemIntakeDocumentType_commonType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "otherTypeDescription": + out.Values[i] = ec._SystemIntakeDocumentType_otherTypeDescription(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var systemIntakeFundingSourceImplementors = []string{"SystemIntakeFundingSource"} + +func (ec *executionContext) _SystemIntakeFundingSource(ctx context.Context, sel ast.SelectionSet, obj *models.SystemIntakeFundingSource) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, systemIntakeFundingSourceImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("SystemIntakeFundingSource") + case "id": + out.Values[i] = ec._SystemIntakeFundingSource_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "fundingNumber": + out.Values[i] = ec._SystemIntakeFundingSource_fundingNumber(ctx, field, obj) + case "source": + out.Values[i] = ec._SystemIntakeFundingSource_source(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var systemIntakeGRBReviewDiscussionImplementors = []string{"SystemIntakeGRBReviewDiscussion"} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussion(ctx context.Context, sel ast.SelectionSet, obj *models.SystemIntakeGRBReviewDiscussion) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, systemIntakeGRBReviewDiscussionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("SystemIntakeGRBReviewDiscussion") + case "initialPost": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussion_initialPost(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "replies": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussion_replies(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var systemIntakeGRBReviewDiscussionPostImplementors = []string{"SystemIntakeGRBReviewDiscussionPost"} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost(ctx context.Context, sel ast.SelectionSet, obj *models.SystemIntakeGRBReviewDiscussionPost) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, systemIntakeGRBReviewDiscussionPostImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("SystemIntakeGRBReviewDiscussionPost") + case "id": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "content": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_content(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "votingRole": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._SystemIntakeGRBReviewDiscussionPost_votingRole(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "grbRole": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { defer func() { if r := recover(); r != nil { ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntakeDocument_canDelete(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } + res = ec._SystemIntakeGRBReviewDiscussionPost_grbRole(ctx, field, obj) return res } @@ -69135,7 +70539,12 @@ func (ec *executionContext) _SystemIntakeDocument(ctx context.Context, sel ast.S } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "canView": + case "systemIntakeID": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_systemIntakeID(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "createdByUserAccount": field := field innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { @@ -69144,7 +70553,7 @@ func (ec *executionContext) _SystemIntakeDocument(ctx context.Context, sel ast.S ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntakeDocument_canView(ctx, field, obj) + res = ec._SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(ctx, field, obj) if res == graphql.Null { atomic.AddUint32(&fs.Invalids, 1) } @@ -69171,95 +70580,46 @@ func (ec *executionContext) _SystemIntakeDocument(ctx context.Context, sel ast.S } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "systemIntakeId": - out.Values[i] = ec._SystemIntakeDocument_systemIntakeId(ctx, field, obj) + case "createdAt": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_createdAt(ctx, field, obj) if out.Values[i] == graphql.Null { atomic.AddUint32(&out.Invalids, 1) } - default: - panic("unknown field " + strconv.Quote(field.Name)) - } - } - out.Dispatch(ctx) - if out.Invalids > 0 { - return graphql.Null - } - - atomic.AddInt32(&ec.deferred, int32(len(deferred))) - - for label, dfs := range deferred { - ec.processDeferredGroup(graphql.DeferredGroup{ - Label: label, - Path: graphql.GetPath(ctx), - FieldSet: dfs, - Context: ctx, - }) - } - - return out -} - -var systemIntakeDocumentTypeImplementors = []string{"SystemIntakeDocumentType"} - -func (ec *executionContext) _SystemIntakeDocumentType(ctx context.Context, sel ast.SelectionSet, obj *models.SystemIntakeDocumentType) graphql.Marshaler { - fields := graphql.CollectFields(ec.OperationContext, sel, systemIntakeDocumentTypeImplementors) + case "modifiedByUserAccount": + field := field - out := graphql.NewFieldSet(fields) - deferred := make(map[string]*graphql.FieldSet) - for i, field := range fields { - switch field.Name { - case "__typename": - out.Values[i] = graphql.MarshalString("SystemIntakeDocumentType") - case "commonType": - out.Values[i] = ec._SystemIntakeDocumentType_commonType(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(ctx, field, obj) + return res } - case "otherTypeDescription": - out.Values[i] = ec._SystemIntakeDocumentType_otherTypeDescription(ctx, field, obj) - default: - panic("unknown field " + strconv.Quote(field.Name)) - } - } - out.Dispatch(ctx) - if out.Invalids > 0 { - return graphql.Null - } - - atomic.AddInt32(&ec.deferred, int32(len(deferred))) - for label, dfs := range deferred { - ec.processDeferredGroup(graphql.DeferredGroup{ - Label: label, - Path: graphql.GetPath(ctx), - FieldSet: dfs, - Context: ctx, - }) - } - - return out -} - -var systemIntakeFundingSourceImplementors = []string{"SystemIntakeFundingSource"} - -func (ec *executionContext) _SystemIntakeFundingSource(ctx context.Context, sel ast.SelectionSet, obj *models.SystemIntakeFundingSource) graphql.Marshaler { - fields := graphql.CollectFields(ec.OperationContext, sel, systemIntakeFundingSourceImplementors) + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) - out := graphql.NewFieldSet(fields) - deferred := make(map[string]*graphql.FieldSet) - for i, field := range fields { - switch field.Name { - case "__typename": - out.Values[i] = graphql.MarshalString("SystemIntakeFundingSource") - case "id": - out.Values[i] = ec._SystemIntakeFundingSource_id(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue } - case "fundingNumber": - out.Values[i] = ec._SystemIntakeFundingSource_fundingNumber(ctx, field, obj) - case "source": - out.Values[i] = ec._SystemIntakeFundingSource_source(ctx, field, obj) + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "modifiedAt": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_modifiedAt(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -74901,6 +76261,114 @@ func (ec *executionContext) unmarshalNSystemIntakeFundingSourceInput2ᚖgithub return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) marshalNSystemIntakeGRBReviewDiscussion2ᚕᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.SystemIntakeGRBReviewDiscussion) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNSystemIntakeGRBReviewDiscussion2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussion(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNSystemIntakeGRBReviewDiscussion2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussion(ctx context.Context, sel ast.SelectionSet, v *models.SystemIntakeGRBReviewDiscussion) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._SystemIntakeGRBReviewDiscussion(ctx, sel, v) +} + +func (ec *executionContext) marshalNSystemIntakeGRBReviewDiscussionPost2ᚕᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPostᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.SystemIntakeGRBReviewDiscussionPost) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNSystemIntakeGRBReviewDiscussionPost2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPost(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNSystemIntakeGRBReviewDiscussionPost2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPost(ctx context.Context, sel ast.SelectionSet, v *models.SystemIntakeGRBReviewDiscussionPost) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._SystemIntakeGRBReviewDiscussionPost(ctx, sel, v) +} + func (ec *executionContext) marshalNSystemIntakeGRBReviewer2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewer(ctx context.Context, sel ast.SelectionSet, v models.SystemIntakeGRBReviewer) graphql.Marshaler { return ec._SystemIntakeGRBReviewer(ctx, sel, &v) } @@ -76719,6 +78187,16 @@ func (ec *executionContext) marshalN__TypeKind2string(ctx context.Context, sel a return res } +func (ec *executionContext) unmarshalNcreateSystemIntakeGRBDiscussionPostInput2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐCreateSystemIntakeGRBDiscussionPostInput(ctx context.Context, v interface{}) (models.CreateSystemIntakeGRBDiscussionPostInput, error) { + res, err := ec.unmarshalInputcreateSystemIntakeGRBDiscussionPostInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalNcreateSystemIntakeGRBDiscussionReplyInput2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐCreateSystemIntakeGRBDiscussionReplyInput(ctx context.Context, v interface{}) (models.CreateSystemIntakeGRBDiscussionReplyInput, error) { + res, err := ec.unmarshalInputcreateSystemIntakeGRBDiscussionReplyInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalOBoolean2bool(ctx context.Context, v interface{}) (bool, error) { res, err := graphql.UnmarshalBoolean(v) return res, graphql.ErrorOnPath(ctx, err) @@ -77397,6 +78875,45 @@ func (ec *executionContext) unmarshalOSystemIntakeFundingSourcesInput2ᚖgithub return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) marshalOSystemIntakeGRBReviewDiscussionPost2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPost(ctx context.Context, sel ast.SelectionSet, v *models.SystemIntakeGRBReviewDiscussionPost) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._SystemIntakeGRBReviewDiscussionPost(ctx, sel, v) +} + +func (ec *executionContext) unmarshalOSystemIntakeGRBReviewerRole2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewerRole(ctx context.Context, v interface{}) (*models.SystemIntakeGRBReviewerRole, error) { + if v == nil { + return nil, nil + } + var res = new(models.SystemIntakeGRBReviewerRole) + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOSystemIntakeGRBReviewerRole2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewerRole(ctx context.Context, sel ast.SelectionSet, v *models.SystemIntakeGRBReviewerRole) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + +func (ec *executionContext) unmarshalOSystemIntakeGRBReviewerVotingRole2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewerVotingRole(ctx context.Context, v interface{}) (*models.SystemIntakeGRBReviewerVotingRole, error) { + if v == nil { + return nil, nil + } + var res = new(models.SystemIntakeGRBReviewerVotingRole) + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOSystemIntakeGRBReviewerVotingRole2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewerVotingRole(ctx context.Context, sel ast.SelectionSet, v *models.SystemIntakeGRBReviewerVotingRole) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + func (ec *executionContext) marshalOSystemIntakeLCIDExpirationChange2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeLCIDExpirationChange(ctx context.Context, sel ast.SelectionSet, v *models.SystemIntakeLCIDExpirationChange) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/pkg/graph/schema.graphql b/pkg/graph/schema.graphql index 64c908337c..72a6da83fd 100644 --- a/pkg/graph/schema.graphql +++ b/pkg/graph/schema.graphql @@ -791,6 +791,10 @@ type SystemIntake { TRB Requests that share a CEDAR System or Contract Number """ relatedTRBRequests: [TRBRequest!]! + """ + GRB Review Discussion Posts/Threads + """ + grbDiscussions: [SystemIntakeGRBReviewDiscussion!]! } type SystemIntakeContractNumber { @@ -1226,6 +1230,34 @@ enum SystemIntakeGRBReviewerVotingRole { NON_VOTING } +type SystemIntakeGRBReviewDiscussionPost { + id: UUID! + content: HTML! + votingRole: SystemIntakeGRBReviewerVotingRole + grbRole: SystemIntakeGRBReviewerRole + systemIntakeID: UUID! + createdByUserAccount: UserAccount! + createdAt: Time! + modifiedByUserAccount: UserAccount + modifiedAt: Time +} + +type SystemIntakeGRBReviewDiscussion { + initialPost: SystemIntakeGRBReviewDiscussionPost! + replies: [SystemIntakeGRBReviewDiscussionPost!]! +} + + +input createSystemIntakeGRBDiscussionPostInput { + systemIntakeID: UUID! + content: HTML! +} + +input createSystemIntakeGRBDiscussionReplyInput { + initialPostID: UUID! + content: HTML! +} + """ Input data used to update the admin lead assigned to a system IT governance request @@ -2519,6 +2551,9 @@ type Mutation { updateSystemIntakeGRBReviewer(input: UpdateSystemIntakeGRBReviewerInput!): SystemIntakeGRBReviewer! deleteSystemIntakeGRBReviewer(input: DeleteSystemIntakeGRBReviewerInput!): UUID! + createSystemIntakeGRBDiscussionPost(input: createSystemIntakeGRBDiscussionPostInput!): SystemIntakeGRBReviewDiscussionPost + createSystemIntakeGRBDiscussionReply(input: createSystemIntakeGRBDiscussionReplyInput!): SystemIntakeGRBReviewDiscussionPost + updateSystemIntakeLinkedCedarSystem(input: UpdateSystemIntakeLinkedCedarSystemInput!): UpdateSystemIntakePayload archiveSystemIntake(id: UUID!): SystemIntake! diff --git a/pkg/graph/schema.resolvers.go b/pkg/graph/schema.resolvers.go index 4c5bdd4219..b6cca5573d 100644 --- a/pkg/graph/schema.resolvers.go +++ b/pkg/graph/schema.resolvers.go @@ -13,6 +13,7 @@ import ( "github.com/google/uuid" "github.com/guregu/null" + "github.com/samber/lo" "golang.org/x/sync/errgroup" "github.com/cms-enterprise/easi-app/pkg/appcontext" @@ -668,6 +669,22 @@ func (r *mutationResolver) DeleteSystemIntakeGRBReviewer(ctx context.Context, in return input.ReviewerID, resolvers.DeleteSystemIntakeGRBReviewer(ctx, r.store, input.ReviewerID) } +// CreateSystemIntakeGRBDiscussionPost is the resolver for the createSystemIntakeGRBDiscussionPost field. +func (r *mutationResolver) CreateSystemIntakeGRBDiscussionPost(ctx context.Context, input models.CreateSystemIntakeGRBDiscussionPostInput) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + principal := appcontext.Principal(ctx).Account().ID + post := models.NewSystemIntakeGRBReviewDiscussion(principal) + post.Content = input.Content + return post, nil +} + +// CreateSystemIntakeGRBDiscussionReply is the resolver for the createSystemIntakeGRBDiscussionReply field. +func (r *mutationResolver) CreateSystemIntakeGRBDiscussionReply(ctx context.Context, input models.CreateSystemIntakeGRBDiscussionReplyInput) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + principal := appcontext.Principal(ctx).Account().ID + post := models.NewSystemIntakeGRBReviewDiscussion(principal) + post.Content = input.Content + return post, nil +} + // UpdateSystemIntakeLinkedCedarSystem is the resolver for the updateSystemIntakeLinkedCedarSystem field. func (r *mutationResolver) UpdateSystemIntakeLinkedCedarSystem(ctx context.Context, input models.UpdateSystemIntakeLinkedCedarSystemInput) (*models.UpdateSystemIntakePayload, error) { // If the linked system is not nil, make sure it's a valid CEDAR system, otherwise return an error @@ -1912,6 +1929,34 @@ func (r *systemIntakeResolver) RelatedTRBRequests(ctx context.Context, obj *mode return resolvers.SystemIntakeRelatedTRBRequests(ctx, obj.ID) } +// GrbDiscussions is the resolver for the grbDiscussions field. +func (r *systemIntakeResolver) GrbDiscussions(ctx context.Context, obj *models.SystemIntake) ([]*models.SystemIntakeGRBReviewDiscussion, error) { + principal := appcontext.Principal(ctx).Account().ID + user1, err := userhelpers.GetOrCreateUserAccount(ctx, r.store, r.store, "USR1", false, userhelpers.GetUserInfoAccountInfoWrapperFunc(r.service.FetchUserInfo)) + if err != nil { + return nil, err + } + initialPost1 := models.NewSystemIntakeGRBReviewDiscussion(principal) + initialPost2 := models.NewSystemIntakeGRBReviewDiscussion(principal) + initialPost1.Content = models.HTML("

This is an initial discussion post.

") + initialPost2.Content = models.HTML("

This is also an initial discussion post.

") + replies := lo.Map([]uuid.UUID{user1.ID, principal, user1.ID}, func(id uuid.UUID, _ int) *models.SystemIntakeGRBReviewDiscussionPost { + post := models.NewSystemIntakeGRBReviewDiscussion(id) + post.Content = models.HTML("

This is a reply

") + return post + }) + return []*models.SystemIntakeGRBReviewDiscussion{ + { + InitialPost: initialPost1, + Replies: replies, + }, + { + InitialPost: initialPost2, + Replies: replies, + }, + }, nil +} + // DocumentType is the resolver for the documentType field. func (r *systemIntakeDocumentResolver) DocumentType(ctx context.Context, obj *models.SystemIntakeDocument) (*models.SystemIntakeDocumentType, error) { return &models.SystemIntakeDocumentType{ @@ -1949,6 +1994,24 @@ func (r *systemIntakeDocumentResolver) CanView(ctx context.Context, obj *models. return resolvers.CanViewDocument(ctx, grbUsers, obj), nil } +// VotingRole is the resolver for the votingRole field. +func (r *systemIntakeGRBReviewDiscussionPostResolver) VotingRole(ctx context.Context, obj *models.SystemIntakeGRBReviewDiscussionPost) (*models.SystemIntakeGRBReviewerVotingRole, error) { + if obj.VotingRole == nil { + return nil, nil + } + strVal := *obj.VotingRole + return helpers.PointerTo(models.SystemIntakeGRBReviewerVotingRole(strVal)), nil +} + +// GrbRole is the resolver for the grbRole field. +func (r *systemIntakeGRBReviewDiscussionPostResolver) GrbRole(ctx context.Context, obj *models.SystemIntakeGRBReviewDiscussionPost) (*models.SystemIntakeGRBReviewerRole, error) { + if obj.GRBRole == nil { + return nil, nil + } + strVal := *obj.GRBRole + return helpers.PointerTo(models.SystemIntakeGRBReviewerRole(strVal)), nil +} + // VotingRole is the resolver for the votingRole field. func (r *systemIntakeGRBReviewerResolver) VotingRole(ctx context.Context, obj *models.SystemIntakeGRBReviewer) (models.SystemIntakeGRBReviewerVotingRole, error) { return models.SystemIntakeGRBReviewerVotingRole(obj.VotingRole), nil @@ -2237,6 +2300,11 @@ func (r *Resolver) SystemIntakeDocument() generated.SystemIntakeDocumentResolver return &systemIntakeDocumentResolver{r} } +// SystemIntakeGRBReviewDiscussionPost returns generated.SystemIntakeGRBReviewDiscussionPostResolver implementation. +func (r *Resolver) SystemIntakeGRBReviewDiscussionPost() generated.SystemIntakeGRBReviewDiscussionPostResolver { + return &systemIntakeGRBReviewDiscussionPostResolver{r} +} + // SystemIntakeGRBReviewer returns generated.SystemIntakeGRBReviewerResolver implementation. func (r *Resolver) SystemIntakeGRBReviewer() generated.SystemIntakeGRBReviewerResolver { return &systemIntakeGRBReviewerResolver{r} @@ -2297,6 +2365,7 @@ type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } type systemIntakeResolver struct{ *Resolver } type systemIntakeDocumentResolver struct{ *Resolver } +type systemIntakeGRBReviewDiscussionPostResolver struct{ *Resolver } type systemIntakeGRBReviewerResolver struct{ *Resolver } type systemIntakeNoteResolver struct{ *Resolver } type tRBAdminNoteResolver struct{ *Resolver } diff --git a/pkg/models/models_gen.go b/pkg/models/models_gen.go index 6b5202976b..90bd0f1165 100644 --- a/pkg/models/models_gen.go +++ b/pkg/models/models_gen.go @@ -616,6 +616,11 @@ type SystemIntakeFundingSourcesInput struct { FundingSources []*SystemIntakeFundingSourceInput `json:"fundingSources"` } +type SystemIntakeGRBReviewDiscussion struct { + InitialPost *SystemIntakeGRBReviewDiscussionPost `json:"initialPost"` + Replies []*SystemIntakeGRBReviewDiscussionPost `json:"replies"` +} + // Contains multiple system request collaborators, if any type SystemIntakeGovernanceTeam struct { IsPresent *bool `json:"isPresent,omitempty"` @@ -963,6 +968,16 @@ type UserError struct { Path []string `json:"path"` } +type CreateSystemIntakeGRBDiscussionPostInput struct { + SystemIntakeID uuid.UUID `json:"systemIntakeID"` + Content HTML `json:"content"` +} + +type CreateSystemIntakeGRBDiscussionReplyInput struct { + InitialPostID uuid.UUID `json:"initialPostID"` + Content HTML `json:"content"` +} + // A user role associated with a job code type Role string diff --git a/pkg/models/system_intake_grb_discussions.go b/pkg/models/system_intake_grb_discussions.go new file mode 100644 index 0000000000..21bb02ef44 --- /dev/null +++ b/pkg/models/system_intake_grb_discussions.go @@ -0,0 +1,27 @@ +package models + +import ( + "github.com/google/uuid" +) + +type SystemIntakeGRBReviewDiscussionPost struct { + BaseStructUser + Content HTML `json:"content" db:"content"` + SystemIntakeID uuid.UUID `json:"systemIntakeId" db:"system_intake_id"` + ReplyToID *uuid.UUID `db:"reply_to_id"` + VotingRole *SIGRBReviewerVotingRole `json:"votingRole" db:"voting_role"` + GRBRole *SIGRBReviewerRole `json:"grbRole" db:"grb_role"` +} + +func NewSystemIntakeGRBReviewDiscussion(createdBy uuid.UUID) *SystemIntakeGRBReviewDiscussionPost { + return &SystemIntakeGRBReviewDiscussionPost{ + BaseStructUser: NewBaseStructUser(createdBy), + } +} + +func (r SystemIntakeGRBReviewDiscussionPost) GetMappingKey() uuid.UUID { + return r.SystemIntakeID +} +func (r SystemIntakeGRBReviewDiscussionPost) GetMappingVal() *SystemIntakeGRBReviewDiscussionPost { + return &r +} diff --git a/pkg/storage/truncate.go b/pkg/storage/truncate.go index d2f3676242..c265854240 100644 --- a/pkg/storage/truncate.go +++ b/pkg/storage/truncate.go @@ -26,6 +26,7 @@ func (s *Store) TruncateAllTablesDANGEROUS(logger *zap.Logger) error { system_intake_documents, system_intake_funding_sources, system_intake_grb_reviewers, + system_intake_internal_grb_review_discussion_posts, system_intake_systems, system_intakes, trb_admin_notes_trb_request_documents_links, diff --git a/scripts/dev b/scripts/dev index 443264ace1..52f05cf9c8 100755 --- a/scripts/dev +++ b/scripts/dev @@ -122,7 +122,7 @@ def up(frontend_included, *args, debug:false, wait:false) "COMPOSE_HTTP_TIMEOUT" => "120", "AIR_CONFIG" =>conf, } - command = "docker compose #{"--profile frontend" if frontend_included } up --build" + command = "docker compose #{"--profile frontend" if frontend_included } up --build --remove-orphans" if args.any? command = "#{command} #{args.join(' ')}" @@ -371,6 +371,7 @@ namespace :db do business_cases governance_request_feedback system_intake_grb_reviewers + system_intake_internal_grb_review_discussion_posts system_intake_contacts system_intake_funding_sources system_intake_documents diff --git a/src/gql/gen/graphql.ts b/src/gql/gen/graphql.ts index 20b6d9e795..e8b8cd85e9 100644 --- a/src/gql/gen/graphql.ts +++ b/src/gql/gen/graphql.ts @@ -965,6 +965,8 @@ export type Mutation = { createSystemIntakeActionUpdateLCID?: Maybe; createSystemIntakeContact?: Maybe; createSystemIntakeDocument?: Maybe; + createSystemIntakeGRBDiscussionPost?: Maybe; + createSystemIntakeGRBDiscussionReply?: Maybe; createSystemIntakeGRBReviewers?: Maybe; createSystemIntakeNote?: Maybe; createTRBAdminNoteConsultSession: TRBAdminNote; @@ -1142,6 +1144,18 @@ export type MutationCreateSystemIntakeDocumentArgs = { }; +/** Defines the mutations for the schema */ +export type MutationCreateSystemIntakeGRBDiscussionPostArgs = { + input: CreateSystemIntakeGRBDiscussionPostInput; +}; + + +/** Defines the mutations for the schema */ +export type MutationCreateSystemIntakeGRBDiscussionReplyArgs = { + input: CreateSystemIntakeGRBDiscussionReplyInput; +}; + + /** Defines the mutations for the schema */ export type MutationCreateSystemIntakeGRBReviewersArgs = { input: CreateSystemIntakeGRBReviewersInput; @@ -1844,6 +1858,8 @@ export type SystemIntake = { governanceRequestFeedbacks: Array; governanceTeams: SystemIntakeGovernanceTeam; grbDate?: Maybe; + /** GRB Review Discussion Posts/Threads */ + grbDiscussions: Array; /** This is a calculated state based on if a date exists for the GRB Meeting date */ grbMeetingState: SystemIntakeMeetingState; grbReviewStartedAt?: Maybe; @@ -2208,6 +2224,25 @@ export type SystemIntakeFundingSourcesInput = { fundingSources: Array; }; +export type SystemIntakeGRBReviewDiscussion = { + __typename: 'SystemIntakeGRBReviewDiscussion'; + initialPost: SystemIntakeGRBReviewDiscussionPost; + replies: Array; +}; + +export type SystemIntakeGRBReviewDiscussionPost = { + __typename: 'SystemIntakeGRBReviewDiscussionPost'; + content: Scalars['HTML']['output']; + createdAt: Scalars['Time']['output']; + createdByUserAccount: UserAccount; + grbRole?: Maybe; + id: Scalars['UUID']['output']; + modifiedAt?: Maybe; + modifiedByUserAccount?: Maybe; + systemIntakeID: Scalars['UUID']['output']; + votingRole?: Maybe; +}; + /** GRB Reviewers for a system intake request */ export type SystemIntakeGRBReviewer = { __typename: 'SystemIntakeGRBReviewer'; @@ -3183,6 +3218,16 @@ export type UserInfo = { lastName: Scalars['String']['output']; }; +export type CreateSystemIntakeGRBDiscussionPostInput = { + content: Scalars['HTML']['input']; + systemIntakeID: Scalars['UUID']['input']; +}; + +export type CreateSystemIntakeGRBDiscussionReplyInput = { + content: Scalars['HTML']['input']; + initialPostID: Scalars['UUID']['input']; +}; + export type CreateSystemIntakeGRBReviewersMutationVariables = Exact<{ input: CreateSystemIntakeGRBReviewersInput; }>; From a33d63a51c16585be3fa1a9b8589c22c6d778f2d Mon Sep 17 00:00:00 2001 From: adamodd <97050498+adamodd@users.noreply.github.com> Date: Mon, 18 Nov 2024 10:20:29 -0800 Subject: [PATCH 05/22] EASI-4634 MentionTextArea Tiptap (#2882) * MentionTextArea Tiptap lib * DiscussionBoard folder DiscussionModalWrapper Sidepanel --- package.json | 9 +- .../MentionTextArea/MentionList.tsx | 111 +++++ src/components/MentionTextArea/index.scss | 104 +++++ src/components/MentionTextArea/index.tsx | 172 ++++++++ src/components/MentionTextArea/suggestion.ts | 129 ++++++ src/components/MentionTextArea/util.tsx | 15 + src/components/Modal/index.scss | 14 - .../__snapshots__/index.test.tsx.snap | 72 +++ src/components/Sidepanel/index.scss | 31 ++ src/components/Sidepanel/index.test.tsx | 51 +++ src/components/Sidepanel/index.tsx | 69 +++ src/stylesheets/custom.scss | 20 + .../DiscussionModalWrapper.tsx | 35 ++ src/views/DiscussionBoard/index.scss | 37 ++ src/views/DiscussionBoard/index.tsx | 46 ++ .../GovernanceReviewTeam/GRBReview/index.tsx | 14 + yarn.lock | 414 +++++++++++++++++- 17 files changed, 1321 insertions(+), 22 deletions(-) create mode 100644 src/components/MentionTextArea/MentionList.tsx create mode 100644 src/components/MentionTextArea/index.scss create mode 100644 src/components/MentionTextArea/index.tsx create mode 100644 src/components/MentionTextArea/suggestion.ts create mode 100644 src/components/MentionTextArea/util.tsx create mode 100644 src/components/Sidepanel/__snapshots__/index.test.tsx.snap create mode 100644 src/components/Sidepanel/index.scss create mode 100644 src/components/Sidepanel/index.test.tsx create mode 100644 src/components/Sidepanel/index.tsx create mode 100644 src/views/DiscussionBoard/DiscussionModalWrapper.tsx create mode 100644 src/views/DiscussionBoard/index.scss create mode 100644 src/views/DiscussionBoard/index.tsx diff --git a/package.json b/package.json index 6dde27fbff..8ce85f0ac5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,11 @@ "@okta/okta-auth-js": "^7.8.0", "@okta/okta-react": "^6.9.0", "@okta/okta-signin-widget": "^7.23.0", + "@tiptap/extension-mention": "^2.9.1", + "@tiptap/pm": "^2.9.1", + "@tiptap/react": "^2.9.1", + "@tiptap/starter-kit": "^2.9.1", + "@tiptap/suggestion": "^2.9.1", "@toast-ui/react-editor": "^3.2.3", "@trussworks/react-uswds": "^3.2.0", "@types/apollo-upload-client": "^17.0.2", @@ -75,6 +80,7 @@ "redux-devtools-extension": "^2.13.9", "redux-saga": "^1.3.0", "redux-saga-routines": "^3.2.2", + "tippy.js": "^6.3.7", "typescript": "^4.9.5", "uuid": "^8.3.2", "wildcard-mock-link": "^2.0.3", @@ -179,7 +185,8 @@ "node-fetch": "2.6.1", "graphql": "15.8.0", "@graphql-typed-document-node/core": "3.2.0", - "@apollo/federation": "0.38.1" + "@apollo/federation": "0.38.1", + "prosemirror-model": "1.23.0" }, "comments": { "on_resolutions": { diff --git a/src/components/MentionTextArea/MentionList.tsx b/src/components/MentionTextArea/MentionList.tsx new file mode 100644 index 0000000000..4570d4b57b --- /dev/null +++ b/src/components/MentionTextArea/MentionList.tsx @@ -0,0 +1,111 @@ +/* MentionList renders the TipTap suggestion dropdown in addition to defining +defining keyboard events */ + +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useState +} from 'react'; +import { useTranslation } from 'react-i18next'; + +import Spinner from 'components/Spinner'; + +import './index.scss'; + +export const SuggestionLoading = () => { + return ( +
+ +
+ ); +}; + +// Handler dropdown scroll event on keypress +const scrollIntoView = () => { + const selectedElm = document.querySelector('.is-selected'); + selectedElm?.scrollIntoView({ block: 'nearest' }); +}; + +const MentionList = forwardRef((props: any, ref) => { + const { t } = useTranslation('discussionsMisc'); + + const [selectedIndex, setSelectedIndex] = useState(0); + + // Sets the selected mention within the editor props + const selectItem = (index: any) => { + const item = props.items[index]; + + if (item) { + props.command({ + id: item.username, + label: item.displayName, + 'tag-type': item.tagType + }); + } + }; + + const upHandler = () => { + setSelectedIndex( + (selectedIndex + props.items?.length - 1) % props.items?.length + ); + scrollIntoView(); + }; + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items?.length); + scrollIntoView(); + }; + + const enterHandler = () => { + selectItem(selectedIndex); + }; + + useEffect(() => setSelectedIndex(0), [props.items]); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: { event: any }) => { + if (event.key === 'ArrowUp' || (event.shiftKey && event.key === 'Tab')) { + upHandler(); + return true; + } + + if ( + event.key === 'ArrowDown' || + (!event.shiftKey && event.key === 'Tab') + ) { + downHandler(); + return true; + } + + if (event.key === 'Enter') { + enterHandler(); + return true; + } + + return false; + } + })); + + return ( +
+ {props.items?.length ? ( + props.items?.map((item: any, index: any) => ( + + )) + ) : ( +
{t('noResults')}
+ )} +
+ ); +}); + +export default MentionList; diff --git a/src/components/MentionTextArea/index.scss b/src/components/MentionTextArea/index.scss new file mode 100644 index 0000000000..a3ee045c73 --- /dev/null +++ b/src/components/MentionTextArea/index.scss @@ -0,0 +1,104 @@ +@use 'uswds-core' as *; + +/* Basic editor styles */ +.tiptap { + border: 1px solid black; + padding: .5rem; + + p { + margin: 0px; + } + + &__readonly { + margin-bottom: 1rem; + + .ProseMirror { + outline: none; + border: none; + padding: 0; + } + + &.notification__content { + p { + quotes: "“" "”"; + + &:first-child::before { + content: open-quote; + } + + &:last-child::after { + content: close-quote; + } + + span.react-renderer.node-mention { + & ~ .ProseMirror-trailingBreak { + display: none; + } + } + } + } + } + + &__editable { + .ProseMirror { + min-height: 155px; + font-size: 16px; + line-height: 22px; + } + } +} + +[data-tippy-root] { + width: 99.7%; + margin-left: .1rem !important; +} + +.tippy-box { + max-width: none !important; +} + +.mention { + color: #005EA2; + border: none; + background-color: transparent; + padding: 0; +} + +.text-base-darker { + .mention { + color: color($theme-color-base-darker); + } +} + +.text-base-darkest { + .mention { + color: color($theme-color-base-darkest); + } +} + +.items { + position: relative; + background: #FFF; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1); + border: 1px solid color($theme-color-base-lighter); + padding: 0; + max-height: 300px; + overflow: auto; +} + +.item { + display: block; + border: none; + margin: 0; + width: 100%; + text-align: left; + background: transparent; + padding-top: 0.65rem; + padding-bottom: 0.65rem; + border-bottom: 1px solid color($theme-color-base-lighter); + min-width: 475px; + + &.is-selected { + background-color: #d9e8f6; + } +} diff --git a/src/components/MentionTextArea/index.tsx b/src/components/MentionTextArea/index.tsx new file mode 100644 index 0000000000..3ccd15bdb4 --- /dev/null +++ b/src/components/MentionTextArea/index.tsx @@ -0,0 +1,172 @@ +import React, { useState } from 'react'; +// import { useTranslation } from 'react-i18next'; +import Mention from '@tiptap/extension-mention'; +import { + EditorContent, + NodeViewWrapper, + ReactNodeViewRenderer, + useEditor +} from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import classNames from 'classnames'; + +// import { sortBy } from 'lodash'; +import Alert from 'components/shared/Alert'; + +import suggestion from './suggestion'; +import { getMentions } from './util'; + +import './index.scss'; + +/* The rendered Mention after selected from MentionList +This component can be any react jsx component, but must be wrapped in +Attrs of selected mention are accessed through node prop */ +const MentionComponent = ({ node }: { node: any }) => { + const { label } = node.attrs; + + // Label may return null if the text was truncated by + // In this case don't render the mention, and shift the line up by the height of the non-rendered label + if (!label) { + return
; + } + + return ( + + {`@${label}`} + + ); +}; + +/* Extended TipTap Mention class with additional attributes +Additionally sets a addNodeView to render custo JSX as mention */ +const CustomMention = Mention.extend({ + atom: true, + selectable: true, + addAttributes() { + return { + ...this.parent?.(), + 'data-id-db': { + default: '' + }, + 'tag-type': { + default: '' + } + }; + }, + addNodeView() { + return ReactNodeViewRenderer(MentionComponent); + } +}); + +const MentionTextArea = ({ + id, + setFieldValue, + editable, + disabled, + initialContent, + className +}: { + id: string; + setFieldValue?: ( + field: string, + value: any, + shouldValidate?: boolean | undefined + ) => void; + editable?: boolean; + disabled?: boolean; + initialContent?: any; + className?: string; +}) => { + // const { t } = useTranslation(''); + + const [tagAlert, setTagAlert] = useState(false); + + const fetchUsers = ({ query }: { query: string }) => { + return [ + { username: 'a', displayName: 'Admin lead', tagType: 'other' }, + { + username: 'b', + displayName: 'Governance Admin Team', + tagType: 'other' + }, + { + username: 'c', + displayName: 'Governance Review Board (GRB)', + tagType: 'other' + }, + { + username: 'OSYC', + displayName: 'Grant Eliezer', + tagType: 'user' + }, + { + username: 'MKCK', + displayName: 'Forest Brown', + tagType: 'user' + }, + { + username: 'PJEA', + displayName: 'Janae Stokes', + tagType: 'user' + } + ]; + }; + + const editor = useEditor( + { + editable: editable && !disabled, + editorProps: { + attributes: { + id + } + }, + extensions: [ + StarterKit, + CustomMention.configure({ + HTMLAttributes: { + class: 'mention' + }, + suggestion: { + ...suggestion, + items: fetchUsers + } + }) + ], + onUpdate: ({ editor: input }: any) => { + // Uses the form setter prop (Formik) for mutation input + if (setFieldValue) { + setFieldValue('content', input?.getHTML()); + } + }, + // Sets a alert of a mention is selected, and users/teams will be emailed + onSelectionUpdate: ({ editor: input }: any) => { + setTagAlert(!!getMentions(input?.getJSON()).length); + }, + content: initialContent + }, + [initialContent, disabled] + ); + + return ( + <> + + + {tagAlert && editable && ( + + {/* t() */} + When you save your discussion, the selected team(s) and individual(s) + will be notified via email. + + )} + + ); +}; + +export default MentionTextArea; diff --git a/src/components/MentionTextArea/suggestion.ts b/src/components/MentionTextArea/suggestion.ts new file mode 100644 index 0000000000..bd74109404 --- /dev/null +++ b/src/components/MentionTextArea/suggestion.ts @@ -0,0 +1,129 @@ +import { ReactRenderer } from '@tiptap/react'; +import tippy from 'tippy.js'; + +import MentionList, { SuggestionLoading } from './MentionList'; + +/* Returns the current textarea/RTE editor dimension to append the Mentionslist dropdown +MentionList should have the same width as this parent clientRect */ +const getClientRect = (props: any) => { + const editorID = props.editor.options.editorProps.attributes.id; + const elem = document.getElementById(editorID); + const rect = elem?.getBoundingClientRect(); + const mentionRect = props.clientRect(); + + return () => + new DOMRect( + rect?.left, + mentionRect.y, + mentionRect.width, + mentionRect.height + ); +}; + +const suggestion = { + allowSpaces: true, + render: () => { + let reactRenderer: any; + let spinner: any; + let popup: any; + + return { + // If we had async initial data - load a spinning symbol until onStart gets called + // We have hardcoded in memory data for current implementation, doesn't currently get called + onBeforeStart: (props: any) => { + const editorID = props.editor.options.editorProps.attributes.id; + + if (!props.clientRect) { + return; + } + + reactRenderer = new ReactRenderer(SuggestionLoading, { + props, + editor: props.editor + }); + + spinner = tippy('body', { + getReferenceClientRect: getClientRect(props), + appendTo: () => document.getElementById(editorID) || document.body, + content: reactRenderer.element, + showOnCreate: true, + interactive: false, + trigger: 'manual', + placement: 'bottom-start' + }); + }, + + // Render any available suggestions when mention trigger is first called - @ + onStart: (props: any) => { + const editorID = props.editor.options.editorProps.attributes.id; + + if (!props.clientRect) { + return; + } + + spinner[0].hide(); + + reactRenderer = new ReactRenderer(MentionList, { + props, + editor: props.editor + }); + + popup = tippy('body', { + getReferenceClientRect: getClientRect(props), + appendTo: () => document.getElementById(editorID) || document.body, + content: reactRenderer.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start' + }); + }, + + // When async data/suggestions return, hide the spinner and show the updated list + onUpdate(props: any) { + reactRenderer.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup[0].setProps({ + getReferenceClientRect: getClientRect(props) + }); + spinner[0].setProps({ + getReferenceClientRect: getClientRect(props) + }); + + spinner[0].hide(); + + popup[0].show(); + }, + + // If a valid character key, render the spinner until onUpdate gets called to rerender updated list + onKeyDown(props: any) { + if (props.event.key === 'Escape') { + popup[0].hide(); + spinner[0].hide(); + + return true; + } + + if (props.event.key.length === 1 || props.event.key === 'Backspace') { + popup[0].hide(); + + spinner[0].show(); + } + + return reactRenderer.ref?.onKeyDown(props); + }, + + onExit() { + popup[0].destroy(); + spinner[0].destroy(); + reactRenderer.destroy(); + } + }; + } +}; + +export default suggestion; diff --git a/src/components/MentionTextArea/util.tsx b/src/components/MentionTextArea/util.tsx new file mode 100644 index 0000000000..3fb38d603f --- /dev/null +++ b/src/components/MentionTextArea/util.tsx @@ -0,0 +1,15 @@ +// Possible Util to extract only mentions from content +// eslint-disable-next-line import/prefer-default-export +export const getMentions = (data: any) => { + const mentions: any = []; + + data?.content?.forEach((para: any) => { + para?.content?.forEach((content: any) => { + if (content?.type === 'mention') { + mentions.push(content?.attrs); + } + }); + }); + + return mentions; +}; diff --git a/src/components/Modal/index.scss b/src/components/Modal/index.scss index 1cd8bcc3a9..29065c585f 100644 --- a/src/components/Modal/index.scss +++ b/src/components/Modal/index.scss @@ -2,33 +2,19 @@ @use 'viewports' as *; .easi-modal { - &__overlay { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - background-color: rgba(0, 0, 0, 0.6); - z-index: 400; - } - &__has-title svg { color: color('primary'); } &__content { - position: absolute; width: 100%; height: 100%; max-height: 90vh; overflow-y: auto; - background-color: color('white'); top: 50%; left: 50%; transform: translate(-50%, -50%); - z-index: 1; font-size: 1.375em; - line-height: 1.6em; @media screen and (min-width: $tablet) { width: 668px; diff --git a/src/components/Sidepanel/__snapshots__/index.test.tsx.snap b/src/components/Sidepanel/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..58a6312b2b --- /dev/null +++ b/src/components/Sidepanel/__snapshots__/index.test.tsx.snap @@ -0,0 +1,72 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Sidepanel > matches snapshot 1`] = ` + +
+
+
+
+ +
+
+ +`; diff --git a/src/components/Sidepanel/index.scss b/src/components/Sidepanel/index.scss new file mode 100644 index 0000000000..db25ab91fe --- /dev/null +++ b/src/components/Sidepanel/index.scss @@ -0,0 +1,31 @@ +@use 'uswds-core' as *; +@use 'viewports' as *; + +.easi-sidepanel { + &__content { + width: 100%; + height: auto; + min-height: 100%; + right: 0; + + @media screen and (min-width: $desktop) { + width: 50%; + } + } + + &__x-button-container { + width: 100%; + box-shadow: 0px .25rem .5rem rgba(0, 0, 0, 0.1); + padding: 1rem; + } + + &__x-button { + background: none; + border: 0; + line-height: 0; + + &:hover { + cursor: pointer; + } + } +} diff --git a/src/components/Sidepanel/index.test.tsx b/src/components/Sidepanel/index.test.tsx new file mode 100644 index 0000000000..824f698fe5 --- /dev/null +++ b/src/components/Sidepanel/index.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import Modal from 'react-modal'; +import { render, waitFor } from '@testing-library/react'; + +import Sidepanel from '.'; + +describe('Sidepanel', () => { + beforeAll(() => { + Modal.setAppElement(document.body); + }); + + it('renders without errors', async () => { + const { getByText, getByTestId } = render( + {}} + isOpen + modalHeading="modalHeading" + testid="testid" + > +
children
+
, + { container: document.body } + ); + + expect(getByTestId('testid')).toBeInTheDocument(); + expect(getByText('modalHeading')).toBeInTheDocument(); + }); + + it('matches snapshot', async () => { + const { asFragment, getByText, getByTestId } = render( + {}} + isOpen + modalHeading="modalHeading" + testid="testid" + > +
children
+
, + { container: document.body } + ); + + await waitFor(() => { + expect(getByTestId('testid')).toBeInTheDocument(); + expect(getByText('modalHeading')).toBeInTheDocument(); + }); + + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/src/components/Sidepanel/index.tsx b/src/components/Sidepanel/index.tsx new file mode 100644 index 0000000000..cbc4834b11 --- /dev/null +++ b/src/components/Sidepanel/index.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import ReactModal from 'react-modal'; +import { Icon } from '@trussworks/react-uswds'; +import classNames from 'classnames'; +import noScroll from 'no-scroll'; + +import './index.scss'; + +type SidepanelProps = { + ariaLabel: string; + children: React.ReactNode | React.ReactNodeArray; + classname?: string; + closeModal: () => void; + isOpen: boolean; + modalHeading: string; + openModal?: () => void; + testid: string; +}; + +const Sidepanel = ({ + ariaLabel, + children, + classname, + closeModal, + isOpen, + modalHeading, + openModal, + testid +}: SidepanelProps) => { + const handleOpenModal = () => { + noScroll.on(); + if (openModal) { + openModal(); + } + }; + + return ( + +
+
+ +

{modalHeading}

+
+ + {children} +
+
+ ); +}; + +export default Sidepanel; diff --git a/src/stylesheets/custom.scss b/src/stylesheets/custom.scss index 3a74b76e2d..cdc4f36ecf 100644 --- a/src/stylesheets/custom.scss +++ b/src/stylesheets/custom.scss @@ -263,3 +263,23 @@ .bg-green-5 { background-color: $green-5; } + +// Modal and Sidepanel shared styles +.easi-modal, .easi-sidepanel { + &__overlay { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: rgba(0, 0, 0, 0.6); + z-index: 400; + } + + &__content { + position: absolute; + background-color: #fff; + z-index: 1; + line-height: 1.6em; + } +} diff --git a/src/views/DiscussionBoard/DiscussionModalWrapper.tsx b/src/views/DiscussionBoard/DiscussionModalWrapper.tsx new file mode 100644 index 0000000000..2273b81fe0 --- /dev/null +++ b/src/views/DiscussionBoard/DiscussionModalWrapper.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Grid, GridContainer } from '@trussworks/react-uswds'; + +import Sidepanel from 'components/Sidepanel'; + +type DiscussionModalWrapperProps = { + isOpen: boolean; + closeModal: () => void; + children: React.ReactNode; +}; + +const DiscussionModalWrapper = ({ + isOpen, + closeModal, + children +}: DiscussionModalWrapperProps) => { + const { t } = useTranslation('discussions'); + + return ( + + + {children} + + + ); +}; + +export default DiscussionModalWrapper; diff --git a/src/views/DiscussionBoard/index.scss b/src/views/DiscussionBoard/index.scss new file mode 100644 index 0000000000..a46b6df997 --- /dev/null +++ b/src/views/DiscussionBoard/index.scss @@ -0,0 +1,37 @@ +@use 'uswds-core' as *; +@use 'viewports' as *; + +.easi-discussions { + &__body { + padding: 4rem 2rem 2rem; + } + + &__connected { + border-left: .25rem solid color('base-lightest'); + margin-left: .9rem; + padding-left: 1.4rem; + margin-top: 0.5rem; + } + + &__not-connected { + padding-left: 2.6rem; + } + + &__single-discussion:last-of-type { + margin-bottom: -1rem; + margin-top: -.5rem; + } +} + +.no-button > .usa-accordion__heading > .usa-accordion__button { + background-size: 0rem !important; +} + +.discussion-accordion > .usa-accordion__content { + padding: 0rem 1rem; + + &:empty { + padding-top: 0; + padding-bottom: 0; + } +} diff --git a/src/views/DiscussionBoard/index.tsx b/src/views/DiscussionBoard/index.tsx new file mode 100644 index 0000000000..65e68ae9c6 --- /dev/null +++ b/src/views/DiscussionBoard/index.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Button, ButtonGroup } from '@trussworks/react-uswds'; + +import MentionTextArea from '../../components/MentionTextArea'; + +import DiscussionModalWrapper from './DiscussionModalWrapper'; + +type DiscussionBoardProps = { + isOpen: boolean; + closeModal: () => void; + id: string; +}; + +function DiscussionBoard({ isOpen, closeModal, id }: DiscussionBoardProps) { + return ( + + {/* Question */} +

+ Start a discussion +

+

+ Have a question or comment that you want to discuss internally with the + Governance Admin Team or other Governance Review Board (GRB) members + involved in this request? Start a discussion and you’ll be notified when + they reply. +

+
+ +
+ + + + +
+ ); +} + +export default DiscussionBoard; diff --git a/src/views/GovernanceReviewTeam/GRBReview/index.tsx b/src/views/GovernanceReviewTeam/GRBReview/index.tsx index 5574121ba2..3a327e28e8 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/index.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/index.tsx @@ -35,6 +35,7 @@ import { GRBReviewFormAction } from 'types/grbReview'; import { formatDateLocal } from 'utils/date'; import DocumentsTable from 'views/SystemIntake/Documents/DocumentsTable'; +import DiscussionBoard from '../../DiscussionBoard'; import ITGovAdminContext from '../ITGovAdminContext'; import GRBReviewerForm from './GRBReviewerForm'; @@ -130,6 +131,8 @@ const GRBReview = ({ [history, isForm, id, mutate, showMessage, t] ); + const [isDiscussionOpen, setIsDiscussionOpen] = useState(false); + return ( <> { @@ -363,6 +366,17 @@ const GRBReview = ({ +
+ +
+ setIsDiscussionOpen(false)} + id="grb-discussion" + /> + Date: Tue, 19 Nov 2024 16:00:01 -0600 Subject: [PATCH 06/22] [EASI-4636] GRB discussions card (#2889) * DiscussionReply component * DiscussionsCard component * Mock types and data * DiscussionReply component layout * Merge remote-tracking branch 'origin/EASI-4635/initial-discussions-api' into EASI-4636/discussions-response-component * Add discussions to GRB review query * Update component types * Translations * Relative date util function * Discussions admin card * Discussion board tips * Rename component * DiscussionPost unit test * Empty discussions state * Discussions unit tests * Replace mock data with query data * Responsive styling * Role fallback text with unit test * Update reply text and unit tests * Add Discussions nav link * Snapshot update * Add `DiscussionBoard` to Discussions component * Update DiscussionPost to handle replies * Unit tests * Removed snapshot Snapshot would always be outdated because of "X days ago" text within component --- src/data/mock/discussions.ts | 65 +++++++ ...viewers.ts => GetSystemIntakeGRBReview.ts} | 11 +- .../SystemIntakeGRBReviewDiscussion.ts | 28 ++++ src/gql/gen/graphql.ts | 74 +++++--- src/i18n/en-US/discussions.ts | 22 +-- src/utils/date.test.ts | 31 ++++ src/utils/date.ts | 36 +++- .../DiscussionBoard/DiscussionPost/index.scss | 9 + .../DiscussionPost/index.test.tsx | 85 ++++++++++ .../DiscussionBoard/DiscussionPost/index.tsx | 127 ++++++++++++++ .../GRBReview/Discussions.test.tsx | 62 +++++++ .../GRBReview/Discussions.tsx | 158 ++++++++++++++++++ .../GRBReviewerForm/AddReviewerFromEua.tsx | 4 +- .../GRBReview/GRBReviewerForm/index.test.tsx | 31 ++-- .../GRBReview/GRBReviewerForm/index.tsx | 9 +- .../GovernanceReviewTeam/GRBReview/index.scss | 9 + .../GRBReview/index.test.tsx | 4 + .../GovernanceReviewTeam/GRBReview/index.tsx | 30 ++-- .../RequestOverview.test.tsx | 14 +- .../GovernanceReviewTeam/RequestOverview.tsx | 10 +- src/views/GovernanceReviewTeam/index.tsx | 8 +- src/views/GovernanceReviewTeam/subNavItems.ts | 4 + 22 files changed, 742 insertions(+), 89 deletions(-) create mode 100644 src/data/mock/discussions.ts rename src/gql/apolloGQL/grbReview/{GetSystemIntakeGRBReviewers.ts => GetSystemIntakeGRBReview.ts} (50%) create mode 100644 src/gql/apolloGQL/grbReview/SystemIntakeGRBReviewDiscussion.ts create mode 100644 src/views/DiscussionBoard/DiscussionPost/index.scss create mode 100644 src/views/DiscussionBoard/DiscussionPost/index.test.tsx create mode 100644 src/views/DiscussionBoard/DiscussionPost/index.tsx create mode 100644 src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx create mode 100644 src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx diff --git a/src/data/mock/discussions.ts b/src/data/mock/discussions.ts new file mode 100644 index 0000000000..a4b759044e --- /dev/null +++ b/src/data/mock/discussions.ts @@ -0,0 +1,65 @@ +import { + SystemIntakeGRBReviewDiscussionFragment, + SystemIntakeGRBReviewerRole, + SystemIntakeGRBReviewerVotingRole +} from 'gql/gen/graphql'; + +import { systemIntake } from './systemIntake'; +import users from './users'; + +const mockDiscussions = ( + systemIntakeID: string = systemIntake.id +): SystemIntakeGRBReviewDiscussionFragment[] => [ + { + __typename: 'SystemIntakeGRBReviewDiscussion', + initialPost: { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: '882357e4-c0b0-44ef-b749-f71879ad7878', + content: + '

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

', + votingRole: SystemIntakeGRBReviewerVotingRole.VOTING, + grbRole: SystemIntakeGRBReviewerRole.SUBJECT_MATTER_EXPERT, + createdByUserAccount: { + __typename: 'UserAccount', + id: '034fa2b3-00ff-4ec6-857e-75291a59df74', + commonName: users[5].commonName + }, + systemIntakeID, + createdAt: '2024-11-12T10:00:00.368862Z' + }, + replies: [ + { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: '4099dbb7-2752-4bf9-a3e1-da225ceb9fae', + content: + '

Nisi nobis consectetur voluptatem neque tempore. Ea nihil sed beatae?

', + votingRole: SystemIntakeGRBReviewerVotingRole.NON_VOTING, + grbRole: SystemIntakeGRBReviewerRole.CMCS_REP, + createdByUserAccount: { + __typename: 'UserAccount', + id: '909a7888-4d6f-4bbf-9b9d-71ec1e2c3068', + commonName: users[1].commonName + }, + systemIntakeID, + createdAt: '2024-11-13T10:00:00.368862Z' + }, + { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: '47b0081d-de33-4514-b68f-a7e2bbf2610f', + content: + '

Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?

', + votingRole: SystemIntakeGRBReviewerVotingRole.VOTING, + grbRole: SystemIntakeGRBReviewerRole.CO_CHAIR_CIO, + createdByUserAccount: { + __typename: 'UserAccount', + id: '601d52be-7baa-4b45-91cd-88b4a5935c3f', + commonName: users[7].commonName + }, + systemIntakeID, + createdAt: '2024-11-13T10:00:00.368862Z' + } + ] + } +]; + +export default mockDiscussions; diff --git a/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReviewers.ts b/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReview.ts similarity index 50% rename from src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReviewers.ts rename to src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReview.ts index b27b4c3ac0..90c6f04a5e 100644 --- a/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReviewers.ts +++ b/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReview.ts @@ -1,18 +1,21 @@ import { gql } from '@apollo/client'; +import SystemIntakeGRBReviewDiscussion from './SystemIntakeGRBReviewDiscussion'; import SystemIntakeGRBReviewer from './SystemIntakeGRBReviewer'; -const GetSystemIntakeGRBReviewers = gql(/* GraphQL */ ` +export default gql(/* GraphQL */ ` ${SystemIntakeGRBReviewer} - query GetSystemIntakeGRBReviewers($id: UUID!) { + ${SystemIntakeGRBReviewDiscussion} + query GetSystemIntakeGRBReview($id: UUID!) { systemIntake(id: $id) { id grbReviewStartedAt grbReviewers { ...SystemIntakeGRBReviewer } + grbDiscussions { + ...SystemIntakeGRBReviewDiscussion + } } } `); - -export default GetSystemIntakeGRBReviewers; diff --git a/src/gql/apolloGQL/grbReview/SystemIntakeGRBReviewDiscussion.ts b/src/gql/apolloGQL/grbReview/SystemIntakeGRBReviewDiscussion.ts new file mode 100644 index 0000000000..6937b7b789 --- /dev/null +++ b/src/gql/apolloGQL/grbReview/SystemIntakeGRBReviewDiscussion.ts @@ -0,0 +1,28 @@ +import { gql } from '@apollo/client'; + +const SystemIntakeGRBReviewDiscussionPost = gql(/* GraphQL */ ` + fragment SystemIntakeGRBReviewDiscussionPost on SystemIntakeGRBReviewDiscussionPost { + id + content + votingRole + grbRole + createdByUserAccount { + id + commonName + } + systemIntakeID + createdAt + } +`); + +export default gql(/* GraphQL */ ` + ${SystemIntakeGRBReviewDiscussionPost} + fragment SystemIntakeGRBReviewDiscussion on SystemIntakeGRBReviewDiscussion { + initialPost { + ...SystemIntakeGRBReviewDiscussionPost + } + replies { + ...SystemIntakeGRBReviewDiscussionPost + } + } +`); diff --git a/src/gql/gen/graphql.ts b/src/gql/gen/graphql.ts index e8b8cd85e9..d577754a1e 100644 --- a/src/gql/gen/graphql.ts +++ b/src/gql/gen/graphql.ts @@ -3249,12 +3249,12 @@ export type GetGRBReviewersComparisonsQueryVariables = Exact<{ export type GetGRBReviewersComparisonsQuery = { __typename: 'Query', compareGRBReviewersByIntakeID: Array<{ __typename: 'GRBReviewerComparisonIntake', id: UUID, requestName: string, reviewers: Array<{ __typename: 'GRBReviewerComparison', id: UUID, grbRole: SystemIntakeGRBReviewerRole, votingRole: SystemIntakeGRBReviewerVotingRole, isCurrentReviewer: boolean, userAccount: { __typename: 'UserAccount', id: UUID, username: string, commonName: string, email: string } }> }> }; -export type GetSystemIntakeGRBReviewersQueryVariables = Exact<{ +export type GetSystemIntakeGRBReviewQueryVariables = Exact<{ id: Scalars['UUID']['input']; }>; -export type GetSystemIntakeGRBReviewersQuery = { __typename: 'Query', systemIntake?: { __typename: 'SystemIntake', id: UUID, grbReviewStartedAt?: Time | null, grbReviewers: Array<{ __typename: 'SystemIntakeGRBReviewer', id: UUID, grbRole: SystemIntakeGRBReviewerRole, votingRole: SystemIntakeGRBReviewerVotingRole, userAccount: { __typename: 'UserAccount', id: UUID, username: string, commonName: string, email: string } }> } | null }; +export type GetSystemIntakeGRBReviewQuery = { __typename: 'Query', systemIntake?: { __typename: 'SystemIntake', id: UUID, grbReviewStartedAt?: Time | null, grbReviewers: Array<{ __typename: 'SystemIntakeGRBReviewer', id: UUID, grbRole: SystemIntakeGRBReviewerRole, votingRole: SystemIntakeGRBReviewerVotingRole, userAccount: { __typename: 'UserAccount', id: UUID, username: string, commonName: string, email: string } }>, grbDiscussions: Array<{ __typename: 'SystemIntakeGRBReviewDiscussion', initialPost: { __typename: 'SystemIntakeGRBReviewDiscussionPost', id: UUID, content: HTML, votingRole?: SystemIntakeGRBReviewerVotingRole | null, grbRole?: SystemIntakeGRBReviewerRole | null, systemIntakeID: UUID, createdAt: Time, createdByUserAccount: { __typename: 'UserAccount', id: UUID, commonName: string } }, replies: Array<{ __typename: 'SystemIntakeGRBReviewDiscussionPost', id: UUID, content: HTML, votingRole?: SystemIntakeGRBReviewerVotingRole | null, grbRole?: SystemIntakeGRBReviewerRole | null, systemIntakeID: UUID, createdAt: Time, createdByUserAccount: { __typename: 'UserAccount', id: UUID, commonName: string } }> }> } | null }; export type SystemIntakeWithReviewRequestedFragment = { __typename: 'SystemIntake', id: UUID, requestName?: string | null, requesterName?: string | null, requesterComponent?: string | null, grbDate?: Time | null }; @@ -3270,6 +3270,10 @@ export type StartGRBReviewMutationVariables = Exact<{ export type StartGRBReviewMutation = { __typename: 'Mutation', startGRBReview?: string | null }; +export type SystemIntakeGRBReviewDiscussionPostFragment = { __typename: 'SystemIntakeGRBReviewDiscussionPost', id: UUID, content: HTML, votingRole?: SystemIntakeGRBReviewerVotingRole | null, grbRole?: SystemIntakeGRBReviewerRole | null, systemIntakeID: UUID, createdAt: Time, createdByUserAccount: { __typename: 'UserAccount', id: UUID, commonName: string } }; + +export type SystemIntakeGRBReviewDiscussionFragment = { __typename: 'SystemIntakeGRBReviewDiscussion', initialPost: { __typename: 'SystemIntakeGRBReviewDiscussionPost', id: UUID, content: HTML, votingRole?: SystemIntakeGRBReviewerVotingRole | null, grbRole?: SystemIntakeGRBReviewerRole | null, systemIntakeID: UUID, createdAt: Time, createdByUserAccount: { __typename: 'UserAccount', id: UUID, commonName: string } }, replies: Array<{ __typename: 'SystemIntakeGRBReviewDiscussionPost', id: UUID, content: HTML, votingRole?: SystemIntakeGRBReviewerVotingRole | null, grbRole?: SystemIntakeGRBReviewerRole | null, systemIntakeID: UUID, createdAt: Time, createdByUserAccount: { __typename: 'UserAccount', id: UUID, commonName: string } }> }; + export type SystemIntakeGRBReviewerFragment = { __typename: 'SystemIntakeGRBReviewer', id: UUID, grbRole: SystemIntakeGRBReviewerRole, votingRole: SystemIntakeGRBReviewerVotingRole, userAccount: { __typename: 'UserAccount', id: UUID, username: string, commonName: string, email: string } }; export type UpdateSystemIntakeGRBReviewerMutationVariables = Exact<{ @@ -3474,6 +3478,30 @@ export const SystemIntakeWithReviewRequestedFragmentDoc = gql` grbDate } `; +export const SystemIntakeGRBReviewDiscussionPostFragmentDoc = gql` + fragment SystemIntakeGRBReviewDiscussionPost on SystemIntakeGRBReviewDiscussionPost { + id + content + votingRole + grbRole + createdByUserAccount { + id + commonName + } + systemIntakeID + createdAt +} + `; +export const SystemIntakeGRBReviewDiscussionFragmentDoc = gql` + fragment SystemIntakeGRBReviewDiscussion on SystemIntakeGRBReviewDiscussion { + initialPost { + ...SystemIntakeGRBReviewDiscussionPost + } + replies { + ...SystemIntakeGRBReviewDiscussionPost + } +} + ${SystemIntakeGRBReviewDiscussionPostFragmentDoc}`; export const SystemIntakeGRBReviewerFragmentDoc = gql` fragment SystemIntakeGRBReviewer on SystemIntakeGRBReviewer { id @@ -3687,50 +3715,54 @@ export type GetGRBReviewersComparisonsQueryHookResult = ReturnType; export type GetGRBReviewersComparisonsSuspenseQueryHookResult = ReturnType; export type GetGRBReviewersComparisonsQueryResult = Apollo.QueryResult; -export const GetSystemIntakeGRBReviewersDocument = gql` - query GetSystemIntakeGRBReviewers($id: UUID!) { +export const GetSystemIntakeGRBReviewDocument = gql` + query GetSystemIntakeGRBReview($id: UUID!) { systemIntake(id: $id) { id grbReviewStartedAt grbReviewers { ...SystemIntakeGRBReviewer } + grbDiscussions { + ...SystemIntakeGRBReviewDiscussion + } } } - ${SystemIntakeGRBReviewerFragmentDoc}`; + ${SystemIntakeGRBReviewerFragmentDoc} +${SystemIntakeGRBReviewDiscussionFragmentDoc}`; /** - * __useGetSystemIntakeGRBReviewersQuery__ + * __useGetSystemIntakeGRBReviewQuery__ * - * To run a query within a React component, call `useGetSystemIntakeGRBReviewersQuery` and pass it any options that fit your needs. - * When your component renders, `useGetSystemIntakeGRBReviewersQuery` returns an object from Apollo Client that contains loading, error, and data properties + * To run a query within a React component, call `useGetSystemIntakeGRBReviewQuery` and pass it any options that fit your needs. + * When your component renders, `useGetSystemIntakeGRBReviewQuery` returns an object from Apollo Client that contains loading, error, and data properties * you can use to render your UI. * * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; * * @example - * const { data, loading, error } = useGetSystemIntakeGRBReviewersQuery({ + * const { data, loading, error } = useGetSystemIntakeGRBReviewQuery({ * variables: { * id: // value for 'id' * }, * }); */ -export function useGetSystemIntakeGRBReviewersQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: GetSystemIntakeGRBReviewersQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { +export function useGetSystemIntakeGRBReviewQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: GetSystemIntakeGRBReviewQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(GetSystemIntakeGRBReviewersDocument, options); + return Apollo.useQuery(GetSystemIntakeGRBReviewDocument, options); } -export function useGetSystemIntakeGRBReviewersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { +export function useGetSystemIntakeGRBReviewLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(GetSystemIntakeGRBReviewersDocument, options); + return Apollo.useLazyQuery(GetSystemIntakeGRBReviewDocument, options); } -export function useGetSystemIntakeGRBReviewersSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) { +export function useGetSystemIntakeGRBReviewSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) { const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} - return Apollo.useSuspenseQuery(GetSystemIntakeGRBReviewersDocument, options); + return Apollo.useSuspenseQuery(GetSystemIntakeGRBReviewDocument, options); } -export type GetSystemIntakeGRBReviewersQueryHookResult = ReturnType; -export type GetSystemIntakeGRBReviewersLazyQueryHookResult = ReturnType; -export type GetSystemIntakeGRBReviewersSuspenseQueryHookResult = ReturnType; -export type GetSystemIntakeGRBReviewersQueryResult = Apollo.QueryResult; +export type GetSystemIntakeGRBReviewQueryHookResult = ReturnType; +export type GetSystemIntakeGRBReviewLazyQueryHookResult = ReturnType; +export type GetSystemIntakeGRBReviewSuspenseQueryHookResult = ReturnType; +export type GetSystemIntakeGRBReviewQueryResult = Apollo.QueryResult; export const GetSystemIntakesWithReviewRequestedDocument = gql` query GetSystemIntakesWithReviewRequested { systemIntakesWithReviewRequested { @@ -4853,6 +4885,8 @@ export type UpdateTrbRequestLeadMutationHookResult = ReturnType; export type UpdateTrbRequestLeadMutationOptions = Apollo.BaseMutationOptions; export const TypedSystemIntakeWithReviewRequestedFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeWithReviewRequested"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntake"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requestName"}},{"kind":"Field","name":{"kind":"Name","value":"requesterName"}},{"kind":"Field","name":{"kind":"Name","value":"requesterComponent"}},{"kind":"Field","name":{"kind":"Name","value":"grbDate"}}]}}]} as unknown as DocumentNode; +export const TypedSystemIntakeGRBReviewDiscussionPostFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussionPost"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussionPost"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"createdByUserAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"systemIntakeID"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; +export const TypedSystemIntakeGRBReviewDiscussionFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussion"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussion"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"initialPost"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussionPost"}}]}},{"kind":"Field","name":{"kind":"Name","value":"replies"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussionPost"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussionPost"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussionPost"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"createdByUserAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"systemIntakeID"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; export const TypedSystemIntakeGRBReviewerFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"userAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; export const TypedTRBAdminNoteInitialRequestFormCategoryDataFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TRBAdminNoteInitialRequestFormCategoryData"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TRBAdminNoteInitialRequestFormCategoryData"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appliesToBasicRequestDetails"}},{"kind":"Field","name":{"kind":"Name","value":"appliesToSubjectAreas"}},{"kind":"Field","name":{"kind":"Name","value":"appliesToAttendees"}}]}}]} as unknown as DocumentNode; export const TypedTRBAdminNoteSupportingDocumentsCategoryDataFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TRBAdminNoteSupportingDocumentsCategoryData"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TRBAdminNoteSupportingDocumentsCategoryData"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fileName"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}}]}}]} as unknown as DocumentNode; @@ -4863,7 +4897,7 @@ export const TypedTRBGuidanceLetterFragmentDoc = {"kind":"Document","definitions export const TypedCreateSystemIntakeGRBReviewersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateSystemIntakeGRBReviewers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateSystemIntakeGRBReviewersInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createSystemIntakeGRBReviewers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"reviewers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"userAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; export const TypedDeleteSystemIntakeGRBReviewerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteSystemIntakeGRBReviewer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteSystemIntakeGRBReviewerInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteSystemIntakeGRBReviewer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const TypedgetGRBReviewersComparisonsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getGRBReviewersComparisons"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"compareGRBReviewersByIntakeID"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requestName"}},{"kind":"Field","name":{"kind":"Name","value":"reviewers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"userAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isCurrentReviewer"}}]}}]}}]}}]} as unknown as DocumentNode; -export const TypedGetSystemIntakeGRBReviewersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSystemIntakeGRBReviewers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"systemIntake"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbReviewStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"grbReviewers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"userAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; +export const TypedGetSystemIntakeGRBReviewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSystemIntakeGRBReview"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"systemIntake"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbReviewStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"grbReviewers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}}]}},{"kind":"Field","name":{"kind":"Name","value":"grbDiscussions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussion"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussionPost"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussionPost"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"createdByUserAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"systemIntakeID"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"userAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussion"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussion"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"initialPost"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussionPost"}}]}},{"kind":"Field","name":{"kind":"Name","value":"replies"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussionPost"}}]}}]}}]} as unknown as DocumentNode; export const TypedGetSystemIntakesWithReviewRequestedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSystemIntakesWithReviewRequested"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"systemIntakesWithReviewRequested"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeWithReviewRequested"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeWithReviewRequested"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntake"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requestName"}},{"kind":"Field","name":{"kind":"Name","value":"requesterName"}},{"kind":"Field","name":{"kind":"Name","value":"requesterComponent"}},{"kind":"Field","name":{"kind":"Name","value":"grbDate"}}]}}]} as unknown as DocumentNode; export const TypedStartGRBReviewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StartGRBReview"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"StartGRBReviewInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startGRBReview"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const TypedUpdateSystemIntakeGRBReviewerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSystemIntakeGRBReviewer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateSystemIntakeGRBReviewerInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSystemIntakeGRBReviewer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"userAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; diff --git a/src/i18n/en-US/discussions.ts b/src/i18n/en-US/discussions.ts index 45ee1ed898..ea11bfad69 100644 --- a/src/i18n/en-US/discussions.ts +++ b/src/i18n/en-US/discussions.ts @@ -14,12 +14,15 @@ const discussions = { discussedTopics_plural: '{{count}} discussions with replies', fieldsMarkedRequired: 'Fields marked with an asterisk () are required.', // TODO: this is in other i18n files, move to general.ts? - nonDiscussedTopics: '{{count}} discussion without replies', - nonDiscussedTopics_plural: '{{count}} discussions without replies', + discussionsWithoutReplies: '{{count}} discussion without replies', + discussionsWithoutReplies_plural: '{{count}} discussions without replies', readMore: 'Read more', // TODO: this is in other i18n files, move to general.ts? repliesInDiscussion: '{{count}} reply in this discussion', repliesInDiscussion_plural: '{{count}} replies in this discussion', + repliesCount: '{{count}} reply', + repliesCount_plural: '{{count}} replies', reply: 'Reply', + lastReply: 'Last reply {{date}} at {{time}}', saveDiscussion: 'Save discussion', startDiscussion: 'Start a new discussion', @@ -30,6 +33,8 @@ const discussions = { alerts: { noDiscussions: 'There are no discussions yet. When a discussion topic is started, it will appear here.', + noDiscussionsStartButton: + 'There are not yet any discussions. .', replyError: 'There was an issue with adding your reply, please try again.', replySuccess: 'Success! Your reply has been added.', @@ -56,8 +61,7 @@ const discussions = { label: 'Tips for using the discussion boards', content: [ 'Start a new discussion thread for each new topic', - // 'Use tags (@) any time you need input from a specific individual or group. Group tags will notify all members of that group. Avalailble group tags: @Governance Review Board, @Governance Admin Team, @Admin Lead', - 'Use tags (@) any time you need input from a specific individual or group. Group tags will notify all members of that group. Avalailble group tags {{groupNames}}', // TODO: wont display @ symbol? + 'Use tags (@) any time you need input from a specific individual or group. Group tags will notify all members of that group. Avalailble group tags: @Governance Review Board, @Governance Admin Team, and @Admin Lead', 'Participating individuals will get an email notification when a new discussion is started, or when they are tagged in a discussion or reply' ] } @@ -65,14 +69,12 @@ const discussions = { // Board Specific Translations governanceReviewBoard: { - adminPanel: { - // description: - // 'Use the discussion boards below to discuss this project. The internal GRB discussion board is a space for the Governance Admin Team and GRB members to discuss privately; the project team will not be able to view discussions there.', - description: - 'Use the discussion boards below to discuss this project. The {{discussionBoardType}} is a space for the {{groupNames}} members to discuss privately; the project team will not be able to view discussions there.' - }, + discussionsDescription: + 'Use the discussion boards below to discuss this project. The internal GRB discussion board is a space for the Governance Admin Team and GRB members to discuss privately; the project team will not be able to view discussions there.', + governanceAdminTeam: 'Governance Admin Team', internal: { label: 'Internal GRB discussion board', // TODO: enum translation? + visibilityRestricted: 'Visibility restricted', // description: // 'Use this discussion board to ask questions or have dicussions with the Governance Admin Team and other Governance Review Board (GRB) members. The conversations here are not visible to the Project team.' description: diff --git a/src/utils/date.test.ts b/src/utils/date.test.ts index 71e9bd73c6..ff25fb83ee 100644 --- a/src/utils/date.test.ts +++ b/src/utils/date.test.ts @@ -5,6 +5,7 @@ import { formatDateLocal, formatDateUtc, getFiscalYear, + getRelativeDate, parseAsUTC } from './date'; @@ -91,3 +92,33 @@ describe('getFiscalYear', () => { expect(getFiscalYear(date)).toEqual(2029); }); }); + +describe('getRelativeDate', () => { + it('returns formatted date after 30 days', () => { + const date = DateTime.fromObject({ year: 2021, month: 3, day: 1 }); + + const formattedDate = date.toFormat('MM/dd/yyyy'); + + const relativeDate = getRelativeDate(date.toISO()); + + expect(relativeDate).toEqual(formattedDate); + }); + + it('formats past relative date', () => { + const days = 3; + + const date = DateTime.now().minus({ days }); + + const relativeDate = getRelativeDate(date.toISO()); + + expect(relativeDate).toEqual(`${days} days ago`); + }); + + it('formats relative date for today', () => { + const date = DateTime.now(); + + const relativeDate = getRelativeDate(date.toISO()); + + expect(relativeDate).toEqual('today'); + }); +}); diff --git a/src/utils/date.ts b/src/utils/date.ts index b36af4ed09..504f6c2822 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -1,4 +1,4 @@ -import { DateTime } from 'luxon'; +import { DateTime, Interval } from 'luxon'; // Used to parse out mintute, day, ,month, and years from ISOString export const parseAsUTC = (date: string) => DateTime.fromISO(date).toUTC(); @@ -72,3 +72,37 @@ export const isDateInPast = (date: string | null): boolean => { } return false; }; + +/** + * If less than 30 days have passed since `date`, returns "today" or "X days ago". + * + * Otherwise, returns formatted date. + */ +export const getRelativeDate = ( + date: string | null, + /** + * Number of days between `date` and now to display relative date + * before switching to formatted date + */ + relativeDateLimit: number = 30 +): string => { + if (!date) return ''; + + const dateTime = DateTime.fromISO(date); + + if (!dateTime.isValid) return ''; + + /** Interval between now and `date` */ + const interval = Interval.fromDateTimes(dateTime, DateTime.now()); + + // Subtract one from the interval count to see how many days since the initial date + const days = interval.count('days') - 1; + + // If more than 30 days have passed, return formatted date + if (days > relativeDateLimit) { + return DateTime.fromISO(date).toFormat('MM/dd/yyyy'); + } + + // Return relative date + return dateTime.toRelativeCalendar({ unit: 'days' }); +}; diff --git a/src/views/DiscussionBoard/DiscussionPost/index.scss b/src/views/DiscussionBoard/DiscussionPost/index.scss new file mode 100644 index 0000000000..e70eecad3a --- /dev/null +++ b/src/views/DiscussionBoard/DiscussionPost/index.scss @@ -0,0 +1,9 @@ +.easi-discussion-post { + &__header { + justify-content: space-between; + } + + &__content * { + line-height: 1.6 !important; + } +} diff --git a/src/views/DiscussionBoard/DiscussionPost/index.test.tsx b/src/views/DiscussionBoard/DiscussionPost/index.test.tsx new file mode 100644 index 0000000000..07b4242916 --- /dev/null +++ b/src/views/DiscussionBoard/DiscussionPost/index.test.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SystemIntakeGRBReviewDiscussionFragment } from 'gql/gen/graphql'; +import i18next from 'i18next'; + +import mockDiscussions from 'data/mock/discussions'; +import { getRelativeDate } from 'utils/date'; + +import DiscussionPost from '.'; + +const [discussion] = mockDiscussions(); +const { initialPost, replies } = discussion; + +describe('DiscussionPost', () => { + it('renders a discussion post with replies', () => { + render( + + ); + + const { + createdByUserAccount: { commonName }, + grbRole, + votingRole, + createdAt + } = initialPost; + + expect(screen.getByText(commonName)).toBeInTheDocument(); + + const formattedRole = `${i18next.t(`grbReview:votingRoles.${votingRole}`)}, ${i18next.t(`grbReview:reviewerRoles.${grbRole}`)}`; + expect( + screen.getByRole('heading', { level: 5, name: formattedRole }) + ).toBeInTheDocument(); + + const dateText = getRelativeDate(createdAt); + expect(screen.getByText(dateText)).toBeInTheDocument(); + + const repliesCount = replies.length; + expect( + screen.getByRole('button', { name: `${repliesCount} replies` }) + ).toBeInTheDocument(); + + const lastReplyAtText = i18next.t('discussions:general.lastReply', { + date: getRelativeDate(replies[0].createdAt, 1), + time: '10:00 AM' + }); + expect(screen.getByText(lastReplyAtText)).toBeInTheDocument(); + }); + + it('renders a discussion post without replies', () => { + render(); + + expect(screen.getByRole('button', { name: 'Reply' })).toBeInTheDocument(); + + expect(screen.queryByTestId('lastReplyAtText')).toBeNull(); + }); + + it('hides discussion reply data', () => { + render(); + + expect(screen.queryByTestId('discussionReplies')).toBeNull(); + }); + + it('displays roles fallback text', () => { + const discussionNoRole: SystemIntakeGRBReviewDiscussionFragment = { + ...discussion, + initialPost: { + ...initialPost, + grbRole: null, + votingRole: null + } + }; + + render( + + ); + + expect(screen.getByText('Governance Admin Team')).toBeInTheDocument(); + }); +}); diff --git a/src/views/DiscussionBoard/DiscussionPost/index.tsx b/src/views/DiscussionBoard/DiscussionPost/index.tsx new file mode 100644 index 0000000000..a005fe628f --- /dev/null +++ b/src/views/DiscussionBoard/DiscussionPost/index.tsx @@ -0,0 +1,127 @@ +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icon } from '@trussworks/react-uswds'; +import { SystemIntakeGRBReviewDiscussionPostFragment } from 'gql/gen/graphql'; +import { upperFirst } from 'lodash'; +import { DateTime } from 'luxon'; + +import { RichTextViewer } from 'components/RichTextEditor'; +import { AvatarCircle } from 'components/shared/Avatar/Avatar'; +import IconButton from 'components/shared/IconButton'; +import { getRelativeDate } from 'utils/date'; + +import './index.scss'; + +type DiscussionPostProps = SystemIntakeGRBReviewDiscussionPostFragment & { + /** + * Array of discussion replies + * + * Leave undefined if rendering reply or to hide discussion reply data + */ + replies?: SystemIntakeGRBReviewDiscussionPostFragment[]; +}; + +/** + * Displays single discussion or reply + */ +const DiscussionPost = ({ replies, ...initialPost }: DiscussionPostProps) => { + const { t } = useTranslation('discussions'); + + const { + content, + grbRole, + votingRole, + createdByUserAccount: userAccount, + createdAt + } = initialPost; + + /** Displays GRB and voting role with fallback if values are null */ + const role = + votingRole && grbRole + ? `${t(`grbReview:votingRoles.${votingRole}`)}, ${t(`grbReview:reviewerRoles.${grbRole}`)}` + : t('governanceReviewBoard.governanceAdminTeam'); + + /** + * Formatted text for date and time of last reply + * + * If more than one day since reply, uses formatted date. + * Otherwise, uses relative date. + */ + const lastReplyAtText = useMemo(() => { + if (!replies || replies.length === 0) return ''; + + const [lastReply] = replies; + + const dateTime = DateTime.fromISO(lastReply.createdAt); + + return t('general.lastReply', { + date: getRelativeDate(lastReply.createdAt, 1), + time: dateTime.toLocaleString(DateTime.TIME_SIMPLE) + }); + }, [replies, t]); + + return ( +
+
+ +
+ +
+
+
+

{userAccount.commonName}

+ +
+ {role} +
+
+ +

+ {upperFirst(getRelativeDate(createdAt))} +

+
+ + {/** + * TODO: + * - Update to use TipTap text area + * - Truncate text after 3 lines with `Read more` button + */} + + + { + // Only render reply data if `replies` is not undefined + replies && ( +
+ null} + className="margin-right-205" + icon={} + unstyled + > + {replies.length > 0 + ? t('general.repliesCount', { count: replies.length }) + : t('general.reply')} + + + {replies.length > 0 && ( +

+ {lastReplyAtText} +

+ )} +
+ ) + } +
+
+ ); +}; + +export default DiscussionPost; diff --git a/src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx b/src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx new file mode 100644 index 0000000000..3a3155754b --- /dev/null +++ b/src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import { SystemIntakeGRBReviewDiscussionFragment } from 'gql/gen/graphql'; + +import mockDiscussions from 'data/mock/discussions'; + +import Discussions from './Discussions'; + +const [discussion] = mockDiscussions(); + +const discussionWithoutReplies: SystemIntakeGRBReviewDiscussionFragment = { + ...discussion, + replies: [] +}; + +describe('Discussions', () => { + it('renders 0 discussions without replies', () => { + render(); + + expect( + screen.getByRole('heading', { name: 'Most recent activity' }) + ).toBeInTheDocument(); + + expect( + screen.getByText('0 discussions without replies') + ).toBeInTheDocument(); + + expect(screen.queryByRole('img', { name: 'warning icon' })).toBeNull(); + + expect(screen.queryByRole('button', { name: 'View' })).toBeNull(); + }); + + it('renders 1 discussion without replies', () => { + render(); + + expect( + screen.getByText('1 discussion without replies') + ).toBeInTheDocument(); + + expect( + screen.getByRole('img', { name: 'warning icon' }) + ).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: 'View' })).toBeInTheDocument(); + }); + + it('renders discussion board with no discussions', () => { + render(); + + expect( + screen.queryByRole('heading', { name: 'Most recent activity' }) + ).toBeNull(); + + const noDiscussionsAlert = screen.getByTestId('alert'); + const startDiscussionButton = within(noDiscussionsAlert).getByRole( + 'button', + { name: 'Start a discussion' } + ); + + expect(startDiscussionButton).toBeInTheDocument(); + }); +}); diff --git a/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx b/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx new file mode 100644 index 0000000000..3e2afe6847 --- /dev/null +++ b/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx @@ -0,0 +1,158 @@ +import React, { useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { Button, Icon } from '@trussworks/react-uswds'; +import classNames from 'classnames'; +import { SystemIntakeGRBReviewDiscussionFragment } from 'gql/gen/graphql'; + +import Alert from 'components/shared/Alert'; +import CollapsableLink from 'components/shared/CollapsableLink'; +import IconButton from 'components/shared/IconButton'; +import DiscussionBoard from 'views/DiscussionBoard'; +import DiscussionPost from 'views/DiscussionBoard/DiscussionPost'; + +type DiscussionsProps = { + grbDiscussions: SystemIntakeGRBReviewDiscussionFragment[]; + className?: string; +}; + +/** Displays recent discussions on GRB Review tab */ +const Discussions = ({ grbDiscussions, className }: DiscussionsProps) => { + const { t } = useTranslation('discussions'); + + const [isDiscussionBoardOpen, setIsDiscussionBoardOpen] = + useState(false); + + const discussionsWithoutRepliesCount = grbDiscussions.filter( + discussion => discussion.replies.length === 0 + ).length; + + const recentDiscussion = + grbDiscussions.length > 0 ? grbDiscussions[0] : undefined; + + return ( + <> + setIsDiscussionBoardOpen(false)} + id="grb-discussion" + /> + +
+

{t('general.label')}

+

+ {t('governanceReviewBoard.discussionsDescription')} +

+ + +
    + {t('general.usageTips.content', { + returnObjects: true + }).map((item, index) => ( +
  • + }} + /> +
  • + ))} +
+
+ +
+
+

+ {t('governanceReviewBoard.internal.label')} +

+

+ + + {t('governanceReviewBoard.internal.visibilityRestricted')} + +

+ +
+ + {/* Discussions without replies */} +
+

+ {discussionsWithoutRepliesCount > 0 && ( + + )} + + {t('general.discussionsWithoutReplies', { + count: discussionsWithoutRepliesCount + })} +

+ + {discussionsWithoutRepliesCount > 0 && ( + setIsDiscussionBoardOpen(true)} + icon={} + iconPosition="after" + unstyled + > + {t('general.view')} + + )} +
+ + {/* Recent discussions */} + {recentDiscussion ? ( + <> +

+ {t('general.mostRecentActivity')} +

+ + + ) : ( + // If no discussions, show alert + + setIsDiscussionBoardOpen(true)} + unstyled + > + text + + ) + }} + /> + + )} +
+
+ + ); +}; + +export default Discussions; diff --git a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/AddReviewerFromEua.tsx b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/AddReviewerFromEua.tsx index 85993a6d58..f2fa86fa1d 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/AddReviewerFromEua.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/AddReviewerFromEua.tsx @@ -5,7 +5,7 @@ import { ErrorMessage } from '@hookform/error-message'; import { yupResolver } from '@hookform/resolvers/yup'; import { Button, Dropdown, Form, FormGroup } from '@trussworks/react-uswds'; import { - GetSystemIntakeGRBReviewersDocument, + GetSystemIntakeGRBReviewDocument, SystemIntakeGRBReviewerFragment, useUpdateSystemIntakeGRBReviewerMutation } from 'gql/gen/graphql'; @@ -51,7 +51,7 @@ const AddReviewerFromEua = ({ const [updateGRBReviewer] = useUpdateSystemIntakeGRBReviewerMutation({ refetchQueries: [ { - query: GetSystemIntakeGRBReviewersDocument, + query: GetSystemIntakeGRBReviewDocument, variables: { id: systemId } } ] diff --git a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx index c5bd0cefa6..a62768b90e 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx @@ -9,9 +9,9 @@ import { GetGRBReviewersComparisonsDocument, GetGRBReviewersComparisonsQuery, GetGRBReviewersComparisonsQueryVariables, - GetSystemIntakeGRBReviewersDocument, - GetSystemIntakeGRBReviewersQuery, - GetSystemIntakeGRBReviewersQueryVariables, + GetSystemIntakeGRBReviewDocument, + GetSystemIntakeGRBReviewQuery, + GetSystemIntakeGRBReviewQueryVariables, SystemIntakeGRBReviewerFragment, SystemIntakeGRBReviewerRole, SystemIntakeGRBReviewerVotingRole, @@ -133,14 +133,14 @@ const updateSystemIntakeGRBReviewerQuery: MockedQuery< } }; -const getSystemIntakeGRBReviewersQuery = ( +const getSystemIntakeGRBReviewQuery = ( reviewer?: SystemIntakeGRBReviewerFragment ): MockedQuery< - GetSystemIntakeGRBReviewersQuery, - GetSystemIntakeGRBReviewersQueryVariables + GetSystemIntakeGRBReviewQuery, + GetSystemIntakeGRBReviewQueryVariables > => ({ request: { - query: GetSystemIntakeGRBReviewersDocument, + query: GetSystemIntakeGRBReviewDocument, variables: { id: systemIntake.id } @@ -151,8 +151,9 @@ const getSystemIntakeGRBReviewersQuery = ( systemIntake: { __typename: 'SystemIntake', id: systemIntake.id, - grbReviewStartedAt: null, - grbReviewers: reviewer ? [reviewer] : [] + grbReviewers: reviewer ? [reviewer] : [], + grbDiscussions: [], + grbReviewStartedAt: null } } } @@ -191,8 +192,8 @@ describe('GRB reviewer form', () => { cedarContactsQuery('Je'), cedarContactsQuery('Jerry Seinfeld'), createSystemIntakeGRBReviewersQuery, - getSystemIntakeGRBReviewersQuery(), - getSystemIntakeGRBReviewersQuery(grbReviewer) + getSystemIntakeGRBReviewQuery(), + getSystemIntakeGRBReviewQuery(grbReviewer) ]} > @@ -202,6 +203,7 @@ describe('GRB reviewer form', () => { {...systemIntake} businessCase={businessCase} grbReviewers={[]} + grbDiscussions={[]} /> @@ -211,6 +213,7 @@ describe('GRB reviewer form', () => { {...systemIntake} businessCase={businessCase} grbReviewers={[grbReviewer]} + grbDiscussions={[]} /> @@ -279,8 +282,8 @@ describe('GRB reviewer form', () => { getGRBReviewersComparisonsQuery, cedarContactsQuery(contactLabel), updateSystemIntakeGRBReviewerQuery, - getSystemIntakeGRBReviewersQuery(grbReviewer), - getSystemIntakeGRBReviewersQuery(updatedGRBReviewer) + getSystemIntakeGRBReviewQuery(grbReviewer), + getSystemIntakeGRBReviewQuery(updatedGRBReviewer) ]} > @@ -290,6 +293,7 @@ describe('GRB reviewer form', () => { {...systemIntake} businessCase={businessCase} grbReviewers={[grbReviewer]} + grbDiscussions={[]} /> @@ -299,6 +303,7 @@ describe('GRB reviewer form', () => { {...systemIntake} businessCase={businessCase} grbReviewers={[updatedGRBReviewer]} + grbDiscussions={[]} /> diff --git a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.tsx b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.tsx index fee0568698..dff06c2757 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.tsx @@ -3,7 +3,7 @@ import { Trans, useTranslation } from 'react-i18next'; import { useHistory, useParams } from 'react-router-dom'; import { Grid, Icon } from '@trussworks/react-uswds'; import { - GetSystemIntakeGRBReviewersDocument, + GetSystemIntakeGRBReviewDocument, SystemIntakeGRBReviewerFragment, useCreateSystemIntakeGRBReviewersMutation } from 'gql/gen/graphql'; @@ -41,12 +41,7 @@ const GRBReviewerForm = ({ }>(); const [mutate] = useCreateSystemIntakeGRBReviewersMutation({ - refetchQueries: [ - { - query: GetSystemIntakeGRBReviewersDocument, - variables: { id: systemId } - } - ] + refetchQueries: [GetSystemIntakeGRBReviewDocument] }); const createGRBReviewers = (reviewers: GRBReviewerFields[]) => diff --git a/src/views/GovernanceReviewTeam/GRBReview/index.scss b/src/views/GovernanceReviewTeam/GRBReview/index.scss index 2cb216e271..45fe39660b 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/index.scss +++ b/src/views/GovernanceReviewTeam/GRBReview/index.scss @@ -24,3 +24,12 @@ button#startGrbReview { } } } + +.grb-discussions .internal-discussions-board { + &__header { + gap: 1rem; + p { + flex: 2 1 auto; + } + } +} diff --git a/src/views/GovernanceReviewTeam/GRBReview/index.test.tsx b/src/views/GovernanceReviewTeam/GRBReview/index.test.tsx index 004b2cbbba..c396214129 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/index.test.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/index.test.tsx @@ -29,6 +29,7 @@ describe('GRB review tab', () => { {...systemIntake} businessCase={businessCase} grbReviewers={[]} + grbDiscussions={[]} /> @@ -54,6 +55,7 @@ describe('GRB review tab', () => { {...systemIntake} businessCase={businessCase} grbReviewers={[]} + grbDiscussions={[]} /> @@ -81,6 +83,7 @@ describe('GRB review tab', () => { businessCase={businessCase} grbReviewers={[]} grbReviewStartedAt={date} + grbDiscussions={[]} /> @@ -133,6 +136,7 @@ describe('GRB review tab', () => { businessCase={businessCase} grbReviewers={grbReviewers} grbReviewStartedAt={null} + grbDiscussions={[]} /> diff --git a/src/views/GovernanceReviewTeam/GRBReview/index.tsx b/src/views/GovernanceReviewTeam/GRBReview/index.tsx index 3a327e28e8..ebd75431ac 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/index.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/index.tsx @@ -12,7 +12,8 @@ import { ModalHeading } from '@trussworks/react-uswds'; import { - GetSystemIntakeGRBReviewersDocument, + GetSystemIntakeGRBReviewDocument, + SystemIntakeGRBReviewDiscussionFragment, SystemIntakeGRBReviewerFragment, useDeleteSystemIntakeGRBReviewerMutation, useStartGRBReviewMutation @@ -35,9 +36,9 @@ import { GRBReviewFormAction } from 'types/grbReview'; import { formatDateLocal } from 'utils/date'; import DocumentsTable from 'views/SystemIntake/Documents/DocumentsTable'; -import DiscussionBoard from '../../DiscussionBoard'; import ITGovAdminContext from '../ITGovAdminContext'; +import Discussions from './Discussions'; import GRBReviewerForm from './GRBReviewerForm'; import ParticipantsTable from './ParticipantsTable'; @@ -50,6 +51,7 @@ type GRBReviewProps = { businessCase: BusinessCaseModel; grbReviewers: SystemIntakeGRBReviewerFragment[]; documents: SystemIntakeDocument[]; + grbDiscussions: SystemIntakeGRBReviewDiscussionFragment[]; grbReviewStartedAt?: string | null; }; @@ -60,6 +62,7 @@ const GRBReview = ({ state, grbReviewers, documents, + grbDiscussions, grbReviewStartedAt }: GRBReviewProps) => { const { t } = useTranslation('grbReview'); @@ -80,12 +83,7 @@ const GRBReview = ({ const { showMessage } = useMessage(); const [mutate] = useDeleteSystemIntakeGRBReviewerMutation({ - refetchQueries: [ - { - query: GetSystemIntakeGRBReviewersDocument, - variables: { id } - } - ] + refetchQueries: [GetSystemIntakeGRBReviewDocument] }); const [startGRBReview] = useStartGRBReviewMutation({ @@ -96,7 +94,7 @@ const GRBReview = ({ }, refetchQueries: [ { - query: GetSystemIntakeGRBReviewersDocument, + query: GetSystemIntakeGRBReviewDocument, variables: { id } } ] @@ -131,8 +129,6 @@ const GRBReview = ({ [history, isForm, id, mutate, showMessage, t] ); - const [isDiscussionOpen, setIsDiscussionOpen] = useState(false); - return ( <> { @@ -366,15 +362,9 @@ const GRBReview = ({ -
- -
- setIsDiscussionOpen(false)} - id="grb-discussion" + { - + @@ -159,7 +159,7 @@ describe('Governance Review Team', () => { - + @@ -186,7 +186,7 @@ describe('Governance Review Team', () => { - + @@ -205,7 +205,7 @@ describe('Governance Review Team', () => { - + @@ -225,7 +225,7 @@ describe('Governance Review Team', () => { - + @@ -247,7 +247,7 @@ describe('Governance Review Team', () => { - + @@ -271,7 +271,7 @@ describe('Governance Review Board', () => { - + diff --git a/src/views/GovernanceReviewTeam/RequestOverview.tsx b/src/views/GovernanceReviewTeam/RequestOverview.tsx index 39de4471a2..283696cc63 100644 --- a/src/views/GovernanceReviewTeam/RequestOverview.tsx +++ b/src/views/GovernanceReviewTeam/RequestOverview.tsx @@ -6,7 +6,10 @@ import { Route, Switch, useParams } from 'react-router-dom'; import { useQuery } from '@apollo/client'; import { Grid } from '@trussworks/react-uswds'; import classnames from 'classnames'; -import { SystemIntakeGRBReviewerFragment } from 'gql/gen/graphql'; +import { + SystemIntakeGRBReviewDiscussionFragment, + SystemIntakeGRBReviewerFragment +} from 'gql/gen/graphql'; import { useFlags } from 'launchdarkly-react-client-sdk'; import MainContent from 'components/MainContent'; @@ -45,11 +48,13 @@ import './index.scss'; type RequestOverviewProps = { grbReviewers: SystemIntakeGRBReviewerFragment[]; grbReviewStartedAt?: string | null; + grbDiscussions: SystemIntakeGRBReviewDiscussionFragment[]; }; const RequestOverview = ({ grbReviewers, - grbReviewStartedAt + grbReviewStartedAt, + grbDiscussions }: RequestOverviewProps) => { const { t } = useTranslation('governanceReviewTeam'); const flags = useFlags(); @@ -211,6 +216,7 @@ const RequestOverview = ({ businessCase={businessCase} grbReviewers={grbReviewers} grbReviewStartedAt={grbReviewStartedAt} + grbDiscussions={grbDiscussions} /> )} /> diff --git a/src/views/GovernanceReviewTeam/index.tsx b/src/views/GovernanceReviewTeam/index.tsx index 269f9bd579..58537b5cc3 100644 --- a/src/views/GovernanceReviewTeam/index.tsx +++ b/src/views/GovernanceReviewTeam/index.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { Route, Switch, useParams } from 'react-router-dom'; -import { useGetSystemIntakeGRBReviewersQuery } from 'gql/gen/graphql'; +import { useGetSystemIntakeGRBReviewQuery } from 'gql/gen/graphql'; import { useFlags } from 'launchdarkly-react-client-sdk'; import PageLoading from 'components/PageLoading'; @@ -24,13 +24,14 @@ const GovernanceReviewTeam = () => { id: string; }>(); - const { data, loading } = useGetSystemIntakeGRBReviewersQuery({ + const { data, loading } = useGetSystemIntakeGRBReviewQuery({ variables: { id } }); - const { grbReviewers, grbReviewStartedAt } = data?.systemIntake || {}; + const { grbReviewers, grbReviewStartedAt, grbDiscussions } = + data?.systemIntake || {}; /** Check if current user is set as GRB reviewer */ const isGrbReviewer: boolean = useMemo(() => { @@ -62,6 +63,7 @@ const GovernanceReviewTeam = () => { diff --git a/src/views/GovernanceReviewTeam/subNavItems.ts b/src/views/GovernanceReviewTeam/subNavItems.ts index 87d9e70c83..b1a80bc0aa 100644 --- a/src/views/GovernanceReviewTeam/subNavItems.ts +++ b/src/views/GovernanceReviewTeam/subNavItems.ts @@ -40,6 +40,10 @@ const subNavItems = ( { route: `/it-governance/${systemId}/grb-review#participants`, text: 'grbReview:participants' + }, + { + route: `/it-governance/${systemId}/grb-review#discussions`, + text: 'discussions:general.label' } ] }, From 95efd7cd746560b3240b4aef91854072b842af38 Mon Sep 17 00:00:00 2001 From: Lee Warrick <32332479+mynar7@users.noreply.github.com> Date: Thu, 21 Nov 2024 10:06:11 -0500 Subject: [PATCH 07/22] [EASI-4635] Discussions store methods and resolvers (#2896) * implement grb discussion store methods and attach to resolvers * update method comments * update postman for discussions * Add functionality in db seed to allow easier user context creation, add discussions seed data, and update postman collection to return more information on GRB Discussion Posts/Replies --------- Co-authored-by: ClayBenson94 --- EASI.postman_collection.json | 44 +++++++++- cmd/devdata/main.go | 51 +++++++++++- cmd/devdata/mock/mock.go | 21 +++-- .../system_intake_grb_review_discussions.go | 53 ++++++++++++ pkg/dataloaders/dataloaders.go | 2 + .../system_intake_grb_discussion_posts.go | 29 +++++++ pkg/graph/resolvers/system_intake_document.go | 2 +- .../system_intake_grb_discussions.go | 80 +++++++++++++++++++ .../resolvers/system_intake_grb_reviewer.go | 14 ++++ pkg/graph/schema.resolvers.go | 34 +------- pkg/models/system_intake_grb_discussions.go | 59 +++++++++++++- .../get_by_id_internal.sql | 13 +++ .../get_by_intake_ids_internal.sql | 13 +++ .../insert_internal.sql | 34 ++++++++ .../system_intake_grb_discussions.go | 25 ++++++ pkg/sqlqueries/system_intake_grb_reviewers.go | 2 - pkg/storage/system_intake_grb_discussions.go | 41 ++++++++++ 17 files changed, 465 insertions(+), 52 deletions(-) create mode 100644 cmd/devdata/system_intake_grb_review_discussions.go create mode 100644 pkg/dataloaders/system_intake_grb_discussion_posts.go create mode 100644 pkg/graph/resolvers/system_intake_grb_discussions.go create mode 100644 pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_id_internal.sql create mode 100644 pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_intake_ids_internal.sql create mode 100644 pkg/sqlqueries/SQL/system_intake_grb_discussions/insert_internal.sql create mode 100644 pkg/sqlqueries/system_intake_grb_discussions.go create mode 100644 pkg/storage/system_intake_grb_discussions.go diff --git a/EASI.postman_collection.json b/EASI.postman_collection.json index b8c664d899..6ca77b1b79 100644 --- a/EASI.postman_collection.json +++ b/EASI.postman_collection.json @@ -1145,7 +1145,7 @@ "body": { "mode": "graphql", "graphql": { - "query": "query getSystemIntakeGRBReviewers($systemIntakeID: UUID!) {\n systemIntake(id: $systemIntakeID) {\n id\n grbDiscussions {\n initialPost {\n content\n createdByUserAccount {\n givenName\n familyName\n }\n }\n replies {\n content\n createdByUserAccount {\n givenName\n familyName\n }\n }\n }\n }\n}", + "query": "query getSystemIntakeGRBReviewers($systemIntakeID: UUID!) {\n systemIntake(id: $systemIntakeID) {\n id\n grbDiscussions {\n initialPost {\n id\n content\n votingRole\n grbRole\n systemIntakeID\n createdAt\n createdByUserAccount {\n givenName\n familyName\n }\n modifiedAt\n modifiedByUserAccount {\n givenName\n familyName\n }\n }\n replies {\n id\n content\n votingRole\n grbRole\n systemIntakeID\n createdAt\n createdByUserAccount {\n givenName\n familyName\n }\n modifiedAt\n modifiedByUserAccount {\n givenName\n familyName\n }\n }\n }\n }\n}", "variables": "{\r\n \"systemIntakeID\": \"{{systemIntakeID}}\"\r\n}" } }, @@ -1165,7 +1165,7 @@ "listen": "test", "script": { "exec": [ - "" + "pm.collectionVariables.set(\"SystemIntakeGRBDiscussionID\", pm.response.json().data.createSystemIntakeGRBDiscussionPost.id)" ], "type": "text/javascript", "packages": {} @@ -1212,7 +1212,7 @@ "mode": "graphql", "graphql": { "query": "mutation createSystemIntakeGRBDiscussionReply($input: createSystemIntakeGRBDiscussionReplyInput!) {\n createSystemIntakeGRBDiscussionReply(input: $input) {\n id\n content\n grbRole\n votingRole\n systemIntakeID\n createdByUserAccount {\n id\n username\n }\n }\n}", - "variables": "{\r\n \"input\": {\r\n \"initialPostID\": \"00000000-0000-0000-0000-000000000000\",\r\n \"content\": \"

monkey kiwi phonebook

\"\r\n }\r\n}" + "variables": "{\r\n \"input\": {\r\n \"initialPostID\": \"{{SystemIntakeGRBDiscussionID}}\",\r\n \"content\": \"

monkey kiwi maduros senor

\"\r\n }\r\n}" } }, "url": { @@ -1510,6 +1510,39 @@ }, "response": [] }, + { + "name": "SystemIntake Get All w/ Discussions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "query systemIntake {\n systemIntakes(openRequests: true) {\n id\n requestName\n grbDiscussions {\n initialPost {\n id\n content\n }\n replies {\n id\n content\n }\n }\n }\n}", + "variables": "" + } + }, + "url": { + "raw": "{{url}}", + "host": [ + "{{url}}" + ] + } + }, + "response": [] + }, { "name": "SystemIntake Get (Workflow state/statuses)", "event": [ @@ -4176,6 +4209,11 @@ { "key": "SystemIntakeGRBReviewerID", "value": "" + }, + { + "key": "SystemIntakeGRBDiscussionID", + "value": "", + "type": "string" } ] } diff --git a/cmd/devdata/main.go b/cmd/devdata/main.go index cf8c8ba164..348641e114 100644 --- a/cmd/devdata/main.go +++ b/cmd/devdata/main.go @@ -15,6 +15,7 @@ import ( "github.com/cms-enterprise/easi-app/cmd/devdata/mock" "github.com/cms-enterprise/easi-app/pkg/appconfig" + "github.com/cms-enterprise/easi-app/pkg/appcontext" "github.com/cms-enterprise/easi-app/pkg/local" "github.com/cms-enterprise/easi-app/pkg/models" "github.com/cms-enterprise/easi-app/pkg/storage" @@ -80,10 +81,29 @@ func main() { s3Client := upload.NewS3Client(s3Cfg) - ctx := context.Background() + // nonUserCtx represents a context that has dataloaders, a logger, and other dependencies EXCEPT a principal + // This allows us to use this context as a base to create other contexts with different users without having to re-create all those dependencies each time. + nonUserCtx := context.Background() + nonUserCtx = mock.CtxWithNewDataloaders(nonUserCtx, store) + nonUserCtx = appcontext.WithLogger(nonUserCtx, logger) + + // userCtx is a local helper function (so we can not have to pass local variables all the time) that adds a principal + // to a context object and returns it. + // Useful for making calls as different types of users within the dev data + userCtx := func(username string, itGovAdmin bool, trbAdmin bool) context.Context { + return mock.CtxWithPrincipal(nonUserCtx, store, username, itGovAdmin, trbAdmin) + } + // userCtxNonAdmin wraps userCtx to allow creating a regular user context (no admin permissions) + userCtxNonAdmin := func(username string) context.Context { + return userCtx(username, false, false) + } + // userCtxITGovAdmin wraps userCtx to allow creating a context with IT Gov Admin permissions + userCtxITGovAdmin := func(username string) context.Context { + return userCtx(username, true, false) + } - ctx = mock.CtxWithNewDataloaders(ctx, store) - ctx = mock.CtxWithLoggerAndPrincipal(ctx, logger, store, mock.PrincipalUser) + // Create a context that most of the dev data can use + ctx := userCtx(mock.PrincipalUser, true, false) localOktaClient := local.NewOktaAPIClient() @@ -284,7 +304,7 @@ func main() { intakeID = uuid.MustParse("61efa6eb-1976-4431-a158-d89cc00ce31d") intake = makeSystemIntakeAndProgressToStep( ctx, - "System Intake with some different GRB Reviewers", + "System Intake with some different GRB Reviewers (and discussions)", &intakeID, mock.PrincipalUser, store, @@ -328,6 +348,29 @@ func main() { }, ) + // Forgive my incredibly uncreative seed data... + // TODO, remove when they're not supported when we add tagging. Just using it now to show this is HTML + + // Initial Post + postA := createSystemIntakeGRBDiscussionPost(ctx, store, intake, models.HTML("Post A (Replies)")) + + // First reply is from an Admin -- default context user USR1 is an Admin + createSystemIntakeGRBDiscussionReply(userCtxITGovAdmin("ADMN"), store, postA.ID, models.HTML("Reply A1")) + + // Then, create a reply from most of the GRB reviewers so we get replies from different non-admin GRB & Voting roles + createSystemIntakeGRBDiscussionReply(ctx, store, postA.ID, models.HTML("Reply A2")) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR2"), store, postA.ID, models.HTML("Reply A2")) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR3"), store, postA.ID, models.HTML("Reply A3")) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR4"), store, postA.ID, models.HTML("Reply A4")) + + // Make one more thread with some replies + postB := createSystemIntakeGRBDiscussionPost(ctx, store, intake, models.HTML("Post B")) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR3"), store, postB.ID, models.HTML("Reply B1")) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR4"), store, postB.ID, models.HTML("Reply B2")) + + // Lastly, create a new initial post with no replies + createSystemIntakeGRBDiscussionPost(ctx, store, intake, models.HTML("Post C (No replies)")) + intakeID = uuid.MustParse("d80cf287-35cb-4e76-b8b3-0467eabd75b8") makeSystemIntakeAndProgressToStep( ctx, diff --git a/cmd/devdata/mock/mock.go b/cmd/devdata/mock/mock.go index 9adc2f0273..d4cf329373 100644 --- a/cmd/devdata/mock/mock.go +++ b/cmd/devdata/mock/mock.go @@ -4,8 +4,6 @@ import ( "context" "fmt" - "go.uber.org/zap" - "github.com/cms-enterprise/easi-app/pkg/appcontext" "github.com/cms-enterprise/easi-app/pkg/authentication" "github.com/cms-enterprise/easi-app/pkg/dataloaders" @@ -37,8 +35,8 @@ func FetchUserInfosMock(ctx context.Context, usernames []string) ([]*models.User return localOktaClient.FetchUserInfos(ctx, usernames) } -// CtxWithLoggerAndPrincipal makes a context with a mocked logger and principal -func CtxWithLoggerAndPrincipal(ctx context.Context, logger *zap.Logger, store *storage.Store, username string) context.Context { +// CtxWithPrincipal makes a context with a principal and automatically calls GetOrCreateUserAccount to populate the user account +func CtxWithPrincipal(ctx context.Context, store *storage.Store, username string, isGRTAdmin bool, isTRBAdmin bool) context.Context { //Future Enhancement: Consider adding this to the seederConfig, and also emb if len(username) < 1 { username = PrincipalUser @@ -51,14 +49,15 @@ func CtxWithLoggerAndPrincipal(ctx context.Context, logger *zap.Logger, store *s } princ := &authentication.EUAPrincipal{ - EUAID: username, - JobCodeEASi: true, - JobCodeGRT: true, - UserAccount: userAccount, + EUAID: username, + JobCodeEASi: true, + JobCodeGRT: isGRTAdmin, + JobCodeTRBAdmin: isTRBAdmin, + UserAccount: userAccount, } - ctx = appcontext.WithLogger(ctx, logger) - ctx = appcontext.WithPrincipal(ctx, princ) - return ctx + + newCtx := appcontext.WithPrincipal(ctx, princ) + return newCtx } func CtxWithNewDataloaders(ctx context.Context, store *storage.Store) context.Context { diff --git a/cmd/devdata/system_intake_grb_review_discussions.go b/cmd/devdata/system_intake_grb_review_discussions.go new file mode 100644 index 0000000000..3b5cf46b73 --- /dev/null +++ b/cmd/devdata/system_intake_grb_review_discussions.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/graph/resolvers" + "github.com/cms-enterprise/easi-app/pkg/models" + "github.com/cms-enterprise/easi-app/pkg/storage" +) + +func createSystemIntakeGRBDiscussionPost( + ctx context.Context, + store *storage.Store, + intake *models.SystemIntake, + content models.HTML, +) *models.SystemIntakeGRBReviewDiscussionPost { + post, err := resolvers.CreateSystemIntakeGRBDiscussionPost( + ctx, + store, + nil, // email client + models.CreateSystemIntakeGRBDiscussionPostInput{ + SystemIntakeID: intake.ID, + Content: content, + }, + ) + if err != nil { + panic(err) + } + return post +} + +func createSystemIntakeGRBDiscussionReply( + ctx context.Context, + store *storage.Store, + initialPostID uuid.UUID, + content models.HTML, +) *models.SystemIntakeGRBReviewDiscussionPost { + reply, err := resolvers.CreateSystemIntakeGRBDiscussionReply( + ctx, + store, + nil, // email client + models.CreateSystemIntakeGRBDiscussionReplyInput{ + InitialPostID: initialPostID, + Content: content, + }, + ) + if err != nil { + panic(err) + } + return reply +} diff --git a/pkg/dataloaders/dataloaders.go b/pkg/dataloaders/dataloaders.go index 493cfd60c9..d9ba99d2b4 100644 --- a/pkg/dataloaders/dataloaders.go +++ b/pkg/dataloaders/dataloaders.go @@ -78,6 +78,7 @@ type Dataloaders struct { SystemIntakeFundingSources *dataloadgen.Loader[uuid.UUID, []*models.SystemIntakeFundingSource] SystemIntakeGovReqFeedback *dataloadgen.Loader[uuid.UUID, []*models.GovernanceRequestFeedback] SystemIntakeGRBReviewers *dataloadgen.Loader[uuid.UUID, []*models.SystemIntakeGRBReviewer] + SystemIntakeGRBDiscussionPosts *dataloadgen.Loader[uuid.UUID, []*models.SystemIntakeGRBReviewDiscussionPost] SystemIntakeNotes *dataloadgen.Loader[uuid.UUID, []*models.SystemIntakeNote] SystemIntakeRelatedSystemIntakes *dataloadgen.Loader[uuid.UUID, []*models.SystemIntake] SystemIntakeRelatedTRBRequests *dataloadgen.Loader[uuid.UUID, []*models.TRBRequest] @@ -120,6 +121,7 @@ func NewDataloaders(store *storage.Store, fetchUserInfos fetchUserInfosFunc, get SystemIntakeFundingSources: dataloadgen.NewLoader(dr.batchSystemIntakeFundingSourcesBySystemIntakeIDs), SystemIntakeGovReqFeedback: dataloadgen.NewLoader(dr.batchSystemIntakeGovReqFeedbackByIntakeIDs), SystemIntakeGRBReviewers: dataloadgen.NewLoader(dr.batchSystemIntakeGRBReviewersBySystemIntakeIDs), + SystemIntakeGRBDiscussionPosts: dataloadgen.NewLoader(dr.batchSystemIntakeGRBDiscussionPostsBySystemIntakeIDs), SystemIntakeNotes: dataloadgen.NewLoader(dr.batchSystemIntakeNotesBySystemIntakeIDs), SystemIntakeRelatedSystemIntakes: dataloadgen.NewLoader(dr.batchRelatedSystemIntakesBySystemIntakeIDs), SystemIntakeRelatedTRBRequests: dataloadgen.NewLoader(dr.batchRelatedTRBRequestsBySystemIntakeIDs), diff --git a/pkg/dataloaders/system_intake_grb_discussion_posts.go b/pkg/dataloaders/system_intake_grb_discussion_posts.go new file mode 100644 index 0000000000..811d7daf25 --- /dev/null +++ b/pkg/dataloaders/system_intake_grb_discussion_posts.go @@ -0,0 +1,29 @@ +package dataloaders + +import ( + "context" + "errors" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/helpers" + "github.com/cms-enterprise/easi-app/pkg/models" +) + +func (d *dataReader) batchSystemIntakeGRBDiscussionPostsBySystemIntakeIDs(ctx context.Context, systemIntakeIDs []uuid.UUID) ([][]*models.SystemIntakeGRBReviewDiscussionPost, []error) { + data, err := d.db.SystemIntakeGRBDiscussionPostsBySystemIntakeIDs(ctx, systemIntakeIDs) + if err != nil { + return nil, []error{err} + } + + return helpers.OneToMany(systemIntakeIDs, data), nil +} + +func GetSystemIntakeGRBDiscussionPostsBySystemIntakeID(ctx context.Context, systemIntakeID uuid.UUID) ([]*models.SystemIntakeGRBReviewDiscussionPost, error) { + loaders, ok := loadersFromCTX(ctx) + if !ok { + return nil, errors.New("unexpected nil loaders in GetSystemIntakeGRBReviewersBySystemIntakeID") + } + + return loaders.SystemIntakeGRBDiscussionPosts.Load(ctx, systemIntakeID) +} diff --git a/pkg/graph/resolvers/system_intake_document.go b/pkg/graph/resolvers/system_intake_document.go index 5307b4666e..c58f7e2168 100644 --- a/pkg/graph/resolvers/system_intake_document.go +++ b/pkg/graph/resolvers/system_intake_document.go @@ -138,7 +138,7 @@ func DeleteSystemIntakeDocument(ctx context.Context, store *storage.Store, id uu // CanViewDocument determines if a user can view a document func CanViewDocument(ctx context.Context, grbUsers []*models.SystemIntakeGRBReviewer, document *models.SystemIntakeDocument) bool { // admins can view - if services.HasRole(ctx, models.RoleEasiGovteam) { + if services.AuthorizeRequireGRTJobCode(ctx) { return true } diff --git a/pkg/graph/resolvers/system_intake_grb_discussions.go b/pkg/graph/resolvers/system_intake_grb_discussions.go new file mode 100644 index 0000000000..b8971c0202 --- /dev/null +++ b/pkg/graph/resolvers/system_intake_grb_discussions.go @@ -0,0 +1,80 @@ +package resolvers + +import ( + "context" + "errors" + + "github.com/jmoiron/sqlx" + + "github.com/cms-enterprise/easi-app/pkg/appcontext" + "github.com/cms-enterprise/easi-app/pkg/email" + "github.com/cms-enterprise/easi-app/pkg/models" + "github.com/cms-enterprise/easi-app/pkg/services" + "github.com/cms-enterprise/easi-app/pkg/sqlutils" + "github.com/cms-enterprise/easi-app/pkg/storage" +) + +// CreateSystemIntakeGRBDiscussionPost creates an initial GRB discussion post +func CreateSystemIntakeGRBDiscussionPost( + ctx context.Context, + store *storage.Store, + emailClient *email.Client, + input models.CreateSystemIntakeGRBDiscussionPostInput, +) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + return sqlutils.WithTransactionRet(ctx, store, func(tx *sqlx.Tx) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + principal := appcontext.Principal(ctx).Account().ID + intakeID := input.SystemIntakeID + principalGRBReviewer, err := GetPrincipalGRBReviewerBySystemIntakeID(ctx, intakeID) + if err != nil { + return nil, err + } + isAdmin := services.AuthorizeRequireGRTJobCode(ctx) + if principalGRBReviewer == nil && !isAdmin { + return nil, errors.New("user not authorized to create discussion post") + } + post := models.NewSystemIntakeGRBReviewDiscussionPost(principal) + post.Content = input.Content + post.SystemIntakeID = intakeID + if principalGRBReviewer != nil { + post.VotingRole = &principalGRBReviewer.VotingRole + post.GRBRole = &principalGRBReviewer.GRBRole + } + return store.CreateSystemIntakeGRBDiscussionPost(ctx, tx, post) + }) +} + +// CreateSystemIntakeGRBDiscussionReply creates a reply to a GRB Discussion post +func CreateSystemIntakeGRBDiscussionReply( + ctx context.Context, + store *storage.Store, + emailClient *email.Client, + input models.CreateSystemIntakeGRBDiscussionReplyInput, +) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + return sqlutils.WithTransactionRet(ctx, store, func(tx *sqlx.Tx) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + initialPost, err := store.GetSystemIntakeGRBDiscussionPostByID(ctx, tx, input.InitialPostID) + if err != nil { + return nil, err + } + intakeID := initialPost.SystemIntakeID + if initialPost.ReplyToID != nil { + return nil, errors.New("only top level posts can be replied to") + } + principalGRBReviewer, err := GetPrincipalGRBReviewerBySystemIntakeID(ctx, intakeID) + if err != nil { + return nil, err + } + isAdmin := services.AuthorizeRequireGRTJobCode(ctx) + if principalGRBReviewer == nil && !isAdmin { + return nil, errors.New("user not authorized to create discussion post") + } + post := models.NewSystemIntakeGRBReviewDiscussionPost(appcontext.Principal(ctx).Account().ID) + post.Content = input.Content + post.SystemIntakeID = intakeID + post.ReplyToID = &initialPost.ID + if principalGRBReviewer != nil { + post.VotingRole = &principalGRBReviewer.VotingRole + post.GRBRole = &principalGRBReviewer.GRBRole + } + return store.CreateSystemIntakeGRBDiscussionPost(ctx, tx, post) + }) +} diff --git a/pkg/graph/resolvers/system_intake_grb_reviewer.go b/pkg/graph/resolvers/system_intake_grb_reviewer.go index d85f3b5baa..1c9adc740c 100644 --- a/pkg/graph/resolvers/system_intake_grb_reviewer.go +++ b/pkg/graph/resolvers/system_intake_grb_reviewer.go @@ -223,3 +223,17 @@ func StartGRBReview( return helpers.PointerTo("started GRB review"), nil }) } + +func GetPrincipalGRBReviewerBySystemIntakeID(ctx context.Context, systemIntakeID uuid.UUID) (*models.SystemIntakeGRBReviewer, error) { + principalUserAcctID := appcontext.Principal(ctx).Account().ID + grbReviewers, err := dataloaders.GetSystemIntakeGRBReviewersBySystemIntakeID(ctx, systemIntakeID) + if err != nil { + return nil, err + } + for _, reviewer := range grbReviewers { + if reviewer != nil && reviewer.UserID == principalUserAcctID { + return reviewer, nil + } + } + return nil, nil +} diff --git a/pkg/graph/schema.resolvers.go b/pkg/graph/schema.resolvers.go index b6cca5573d..6dc42b4a17 100644 --- a/pkg/graph/schema.resolvers.go +++ b/pkg/graph/schema.resolvers.go @@ -13,7 +13,6 @@ import ( "github.com/google/uuid" "github.com/guregu/null" - "github.com/samber/lo" "golang.org/x/sync/errgroup" "github.com/cms-enterprise/easi-app/pkg/appcontext" @@ -671,18 +670,12 @@ func (r *mutationResolver) DeleteSystemIntakeGRBReviewer(ctx context.Context, in // CreateSystemIntakeGRBDiscussionPost is the resolver for the createSystemIntakeGRBDiscussionPost field. func (r *mutationResolver) CreateSystemIntakeGRBDiscussionPost(ctx context.Context, input models.CreateSystemIntakeGRBDiscussionPostInput) (*models.SystemIntakeGRBReviewDiscussionPost, error) { - principal := appcontext.Principal(ctx).Account().ID - post := models.NewSystemIntakeGRBReviewDiscussion(principal) - post.Content = input.Content - return post, nil + return resolvers.CreateSystemIntakeGRBDiscussionPost(ctx, r.store, r.emailClient, input) } // CreateSystemIntakeGRBDiscussionReply is the resolver for the createSystemIntakeGRBDiscussionReply field. func (r *mutationResolver) CreateSystemIntakeGRBDiscussionReply(ctx context.Context, input models.CreateSystemIntakeGRBDiscussionReplyInput) (*models.SystemIntakeGRBReviewDiscussionPost, error) { - principal := appcontext.Principal(ctx).Account().ID - post := models.NewSystemIntakeGRBReviewDiscussion(principal) - post.Content = input.Content - return post, nil + return resolvers.CreateSystemIntakeGRBDiscussionReply(ctx, r.store, r.emailClient, input) } // UpdateSystemIntakeLinkedCedarSystem is the resolver for the updateSystemIntakeLinkedCedarSystem field. @@ -1931,30 +1924,11 @@ func (r *systemIntakeResolver) RelatedTRBRequests(ctx context.Context, obj *mode // GrbDiscussions is the resolver for the grbDiscussions field. func (r *systemIntakeResolver) GrbDiscussions(ctx context.Context, obj *models.SystemIntake) ([]*models.SystemIntakeGRBReviewDiscussion, error) { - principal := appcontext.Principal(ctx).Account().ID - user1, err := userhelpers.GetOrCreateUserAccount(ctx, r.store, r.store, "USR1", false, userhelpers.GetUserInfoAccountInfoWrapperFunc(r.service.FetchUserInfo)) + posts, err := dataloaders.GetSystemIntakeGRBDiscussionPostsBySystemIntakeID(ctx, obj.ID) if err != nil { return nil, err } - initialPost1 := models.NewSystemIntakeGRBReviewDiscussion(principal) - initialPost2 := models.NewSystemIntakeGRBReviewDiscussion(principal) - initialPost1.Content = models.HTML("

This is an initial discussion post.

") - initialPost2.Content = models.HTML("

This is also an initial discussion post.

") - replies := lo.Map([]uuid.UUID{user1.ID, principal, user1.ID}, func(id uuid.UUID, _ int) *models.SystemIntakeGRBReviewDiscussionPost { - post := models.NewSystemIntakeGRBReviewDiscussion(id) - post.Content = models.HTML("

This is a reply

") - return post - }) - return []*models.SystemIntakeGRBReviewDiscussion{ - { - InitialPost: initialPost1, - Replies: replies, - }, - { - InitialPost: initialPost2, - Replies: replies, - }, - }, nil + return models.CreateGRBDiscussionsFromPosts(posts) } // DocumentType is the resolver for the documentType field. diff --git a/pkg/models/system_intake_grb_discussions.go b/pkg/models/system_intake_grb_discussions.go index 21bb02ef44..0a0cf8a035 100644 --- a/pkg/models/system_intake_grb_discussions.go +++ b/pkg/models/system_intake_grb_discussions.go @@ -1,6 +1,8 @@ package models import ( + "errors" + "github.com/google/uuid" ) @@ -13,12 +15,67 @@ type SystemIntakeGRBReviewDiscussionPost struct { GRBRole *SIGRBReviewerRole `json:"grbRole" db:"grb_role"` } -func NewSystemIntakeGRBReviewDiscussion(createdBy uuid.UUID) *SystemIntakeGRBReviewDiscussionPost { +func NewSystemIntakeGRBReviewDiscussionPost(createdBy uuid.UUID) *SystemIntakeGRBReviewDiscussionPost { return &SystemIntakeGRBReviewDiscussionPost{ BaseStructUser: NewBaseStructUser(createdBy), } } +// CreateGRBDiscussionsFromPosts sorts a slice of discussion posts (replies and initial) and sorts them into multiple discussions +func CreateGRBDiscussionsFromPosts(posts []*SystemIntakeGRBReviewDiscussionPost) ([]*SystemIntakeGRBReviewDiscussion, error) { + postMap := map[uuid.UUID][]*SystemIntakeGRBReviewDiscussionPost{} + for _, post := range posts { + // shouldn't happen but we deference below + if post == nil { + return nil, errors.New("post is nil") + } + // group posts into slices by the initial post id + var groupingID uuid.UUID + if post.ReplyToID != nil { + groupingID = *post.ReplyToID + } else { + groupingID = post.ID + } + posts, ok := postMap[groupingID] + if ok { + postMap[groupingID] = append(posts, post) + } else { + postMap[groupingID] = []*SystemIntakeGRBReviewDiscussionPost{post} + } + } + + // after grouping by initial post ID, loop through slice of slices and convert groups of posts into discussions + discussions := []*SystemIntakeGRBReviewDiscussion{} + for _, posts := range postMap { + groupedPosts, err := CreateGRBDiscussionFromPosts(posts) + if err != nil { + return nil, err + } + discussions = append(discussions, groupedPosts) + } + return discussions, nil +} + +// CreateGRBDiscussionFromPosts organizes a post and its replies into a single discussion +// (for slices with multiple initial posts/related replies use CreateGRBDiscussionsFromPosts) +func CreateGRBDiscussionFromPosts(posts []*SystemIntakeGRBReviewDiscussionPost) (*SystemIntakeGRBReviewDiscussion, error) { + var discussion SystemIntakeGRBReviewDiscussion + for _, post := range posts { + if post.ReplyToID == nil { + if discussion.InitialPost != nil { + return nil, errors.New("posts must only contain one initial post") + } + discussion.InitialPost = post + } else { + discussion.Replies = append(discussion.Replies, post) + } + } + if discussion.InitialPost == nil { + return nil, errors.New("initial post not found") + } + return &discussion, nil +} + func (r SystemIntakeGRBReviewDiscussionPost) GetMappingKey() uuid.UUID { return r.SystemIntakeID } diff --git a/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_id_internal.sql b/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_id_internal.sql new file mode 100644 index 0000000000..31dde88173 --- /dev/null +++ b/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_id_internal.sql @@ -0,0 +1,13 @@ +SELECT + id, + content, + voting_role, + grb_role, + system_intake_id, + reply_to_id, + modified_at, + modified_by, + created_at, + created_by +FROM system_intake_internal_grb_review_discussion_posts +WHERE id = :id; diff --git a/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_intake_ids_internal.sql b/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_intake_ids_internal.sql new file mode 100644 index 0000000000..1682a91c51 --- /dev/null +++ b/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_intake_ids_internal.sql @@ -0,0 +1,13 @@ +SELECT + id, + content, + voting_role, + grb_role, + system_intake_id, + reply_to_id, + modified_at, + modified_by, + created_at, + created_by +FROM system_intake_internal_grb_review_discussion_posts +WHERE system_intake_id = ANY(:system_intake_ids); diff --git a/pkg/sqlqueries/SQL/system_intake_grb_discussions/insert_internal.sql b/pkg/sqlqueries/SQL/system_intake_grb_discussions/insert_internal.sql new file mode 100644 index 0000000000..9e85b2378d --- /dev/null +++ b/pkg/sqlqueries/SQL/system_intake_grb_discussions/insert_internal.sql @@ -0,0 +1,34 @@ +INSERT INTO system_intake_internal_grb_review_discussion_posts ( + id, + content, + voting_role, + grb_role, + system_intake_id, + reply_to_id, + created_by, + created_at, + modified_by, + modified_at +) VALUES ( + :id, + :content, + :voting_role, + :grb_role, + :system_intake_id, + :reply_to_id, + :created_by, + :created_at, + :modified_by, + :modified_at +) +RETURNING +id, +content, +voting_role, +grb_role, +system_intake_id, +reply_to_id, +created_by, +created_at, +modified_by, +modified_at; diff --git a/pkg/sqlqueries/system_intake_grb_discussions.go b/pkg/sqlqueries/system_intake_grb_discussions.go new file mode 100644 index 0000000000..7301212054 --- /dev/null +++ b/pkg/sqlqueries/system_intake_grb_discussions.go @@ -0,0 +1,25 @@ +package sqlqueries + +import _ "embed" + +//go:embed SQL/system_intake_grb_discussions/insert_internal.sql +var insertSystemIntakeGRBDiscussionSQL string + +//go:embed SQL/system_intake_grb_discussions/get_by_id_internal.sql +var getSystemIntakeGRBDiscussionsByIDSQL string + +//go:embed SQL/system_intake_grb_discussions/get_by_intake_ids_internal.sql +var getSystemIntakeGRBDiscussionsByIntakeIDsSQL string + +// SystemIntakeGRBDiscussion holds all SQL scripts for GRB Discussions +var SystemIntakeGRBDiscussion = systemIntakeGRBDiscussionScripts{ + Create: insertSystemIntakeGRBDiscussionSQL, + GetBySystemIntakeIDs: getSystemIntakeGRBDiscussionsByIntakeIDsSQL, + GetByID: getSystemIntakeGRBDiscussionsByIDSQL, +} + +type systemIntakeGRBDiscussionScripts struct { + Create string + GetByID string + GetBySystemIntakeIDs string +} diff --git a/pkg/sqlqueries/system_intake_grb_reviewers.go b/pkg/sqlqueries/system_intake_grb_reviewers.go index ed089d2fc6..9495aeec12 100644 --- a/pkg/sqlqueries/system_intake_grb_reviewers.go +++ b/pkg/sqlqueries/system_intake_grb_reviewers.go @@ -2,8 +2,6 @@ package sqlqueries import _ "embed" -// deleteSystemIntakeSystemsSQL holds the SQL command to remove all linked systems from a System Intake -// //go:embed SQL/system_intake_grb_reviewer/insert.sql var insertSystemIntakeGRBReviewerSQL string diff --git a/pkg/storage/system_intake_grb_discussions.go b/pkg/storage/system_intake_grb_discussions.go new file mode 100644 index 0000000000..9fc59fe710 --- /dev/null +++ b/pkg/storage/system_intake_grb_discussions.go @@ -0,0 +1,41 @@ +package storage + +import ( + "context" + + "github.com/google/uuid" + "github.com/lib/pq" + + "github.com/cms-enterprise/easi-app/pkg/models" + "github.com/cms-enterprise/easi-app/pkg/sqlqueries" + "github.com/cms-enterprise/easi-app/pkg/sqlutils" +) + +func (s *Store) CreateSystemIntakeGRBDiscussionPost(ctx context.Context, np sqlutils.NamedPreparer, post *models.SystemIntakeGRBReviewDiscussionPost) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + if post.ID == uuid.Nil { + post.ID = uuid.New() + } + createdPost := &models.SystemIntakeGRBReviewDiscussionPost{} + return createdPost, namedGet(ctx, np, createdPost, sqlqueries.SystemIntakeGRBDiscussion.Create, post) +} + +func (s *Store) GetSystemIntakeGRBDiscussionPostByID( + ctx context.Context, + np sqlutils.NamedPreparer, + discussionID uuid.UUID, +) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + post := &models.SystemIntakeGRBReviewDiscussionPost{} + return post, namedGet(ctx, np, post, sqlqueries.SystemIntakeGRBDiscussion.GetByID, args{ + "id": discussionID, + }) +} + +func (s *Store) SystemIntakeGRBDiscussionPostsBySystemIntakeIDs( + ctx context.Context, + systemIntakeIDs []uuid.UUID, +) ([]*models.SystemIntakeGRBReviewDiscussionPost, error) { + posts := []*models.SystemIntakeGRBReviewDiscussionPost{} + return posts, namedSelect(ctx, s, &posts, sqlqueries.SystemIntakeGRBDiscussion.GetBySystemIntakeIDs, args{ + "system_intake_ids": pq.Array(systemIntakeIDs), + }) +} From 820a81d7b37c651fa049c354fa9511152f3ad1d8 Mon Sep 17 00:00:00 2001 From: Ashley Terstriep <60187543+aterstriep@users.noreply.github.com> Date: Fri, 22 Nov 2024 09:48:24 -0600 Subject: [PATCH 08/22] [EASI-4639] Discussions panel views (#2895) * Merge discussions card work into branch * Discussions panel view components * Discussion post and reply mutations * Basic discussion form * Fix line height within side panel * Display discussion and replies * Discussions list view * Threaded replies styling * Show/hide replies * DiscussionsList component * Add DiscussionsList to Discussion view * Add DiscussionsList to discussion board view * Create discussion and reply mutations * Discussion board list filtering * ViewDiscussions unit tests * Discussion component unit tests * Success and error messages * Feature branch merge conflict fix * MentionTextArea in DiscussionForm (#2898) * MentionTextArea in DiscussionForm * Mention area height-auto * Text * Rhf-like signature * .usa-textarea + overrides * Cleanup * MentionTextArea React.forwardRef * Cleanup * Fix render error when review has no discussions * Hide replies if array is empty --------- Co-authored-by: adamodd <97050498+adamodd@users.noreply.github.com> --- src/components/MentionTextArea/index.scss | 15 +- src/components/MentionTextArea/index.tsx | 159 ++++++++-------- src/data/mock/discussions.ts | 80 +++++++- .../CreateSystemIntakeGRBDiscussionPost.ts | 11 ++ .../CreateSystemIntakeGRBDiscussionReply.ts | 11 ++ src/gql/gen/graphql.ts | 82 +++++++++ src/i18n/en-US/discussions.ts | 40 ++-- src/types/discussions.ts | 5 + src/validations/discussionSchema.ts | 8 + src/views/DiscussionBoard/Discussion.test.tsx | 151 +++++++++++++++ src/views/DiscussionBoard/Discussion.tsx | 89 +++++++++ .../DiscussionModalWrapper.tsx | 4 +- .../DiscussionBoard/DiscussionPost/index.scss | 9 - src/views/DiscussionBoard/StartDiscussion.tsx | 43 +++++ .../DiscussionBoard/ViewDiscussions.test.tsx | 114 ++++++++++++ src/views/DiscussionBoard/ViewDiscussions.tsx | 129 +++++++++++++ .../components/DiscussionForm.tsx | 173 ++++++++++++++++++ .../components/DiscussionPost/index.scss | 16 ++ .../DiscussionPost/index.test.tsx | 2 +- .../{ => components}/DiscussionPost/index.tsx | 7 +- .../components/DiscussionsList.tsx | 71 +++++++ src/views/DiscussionBoard/index.scss | 54 +++--- src/views/DiscussionBoard/index.tsx | 83 ++++++--- .../GRBReview/Discussions.test.tsx | 21 ++- .../GRBReview/Discussions.tsx | 12 +- .../GovernanceReviewTeam/GRBReview/index.tsx | 1 + 26 files changed, 1214 insertions(+), 176 deletions(-) create mode 100644 src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionPost.ts create mode 100644 src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionReply.ts create mode 100644 src/types/discussions.ts create mode 100644 src/validations/discussionSchema.ts create mode 100644 src/views/DiscussionBoard/Discussion.test.tsx create mode 100644 src/views/DiscussionBoard/Discussion.tsx delete mode 100644 src/views/DiscussionBoard/DiscussionPost/index.scss create mode 100644 src/views/DiscussionBoard/StartDiscussion.tsx create mode 100644 src/views/DiscussionBoard/ViewDiscussions.test.tsx create mode 100644 src/views/DiscussionBoard/ViewDiscussions.tsx create mode 100644 src/views/DiscussionBoard/components/DiscussionForm.tsx create mode 100644 src/views/DiscussionBoard/components/DiscussionPost/index.scss rename src/views/DiscussionBoard/{ => components}/DiscussionPost/index.test.tsx (97%) rename src/views/DiscussionBoard/{ => components}/DiscussionPost/index.tsx (95%) create mode 100644 src/views/DiscussionBoard/components/DiscussionsList.tsx diff --git a/src/components/MentionTextArea/index.scss b/src/components/MentionTextArea/index.scss index a3ee045c73..aea87e4614 100644 --- a/src/components/MentionTextArea/index.scss +++ b/src/components/MentionTextArea/index.scss @@ -2,9 +2,6 @@ /* Basic editor styles */ .tiptap { - border: 1px solid black; - padding: .5rem; - p { margin: 0px; } @@ -45,6 +42,18 @@ font-size: 16px; line-height: 22px; } + + &.usa-textarea { + padding: 0; + + div[contenteditable] { + font-family: inherit; + font-size: inherit; + line-height: inherit; + height: inherit; + padding: .5rem; + } + } } } diff --git a/src/components/MentionTextArea/index.tsx b/src/components/MentionTextArea/index.tsx index 3ccd15bdb4..b4c54f0cb9 100644 --- a/src/components/MentionTextArea/index.tsx +++ b/src/components/MentionTextArea/index.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; // import { useTranslation } from 'react-i18next'; import Mention from '@tiptap/extension-mention'; import { @@ -10,7 +11,6 @@ import { import StarterKit from '@tiptap/starter-kit'; import classNames from 'classnames'; -// import { sortBy } from 'lodash'; import Alert from 'components/shared/Alert'; import suggestion from './suggestion'; @@ -58,62 +58,57 @@ const CustomMention = Mention.extend({ } }); -const MentionTextArea = ({ - id, - setFieldValue, - editable, - disabled, - initialContent, - className -}: { - id: string; - setFieldValue?: ( - field: string, - value: any, - shouldValidate?: boolean | undefined - ) => void; - editable?: boolean; - disabled?: boolean; - initialContent?: any; - className?: string; -}) => { - // const { t } = useTranslation(''); +const MentionTextArea = React.forwardRef< + HTMLDivElement, + { + id: string; + setFieldValue?: (value: any, shouldValidate?: boolean | undefined) => void; + editable?: boolean; + disabled?: boolean; + initialContent?: string; + className?: string; + } +>( + ( + { id, setFieldValue, editable, disabled, initialContent, className }, + ref + ) => { + const { t } = useTranslation('discussions'); - const [tagAlert, setTagAlert] = useState(false); + const [tagAlert, setTagAlert] = useState(false); - const fetchUsers = ({ query }: { query: string }) => { - return [ - { username: 'a', displayName: 'Admin lead', tagType: 'other' }, - { - username: 'b', - displayName: 'Governance Admin Team', - tagType: 'other' - }, - { - username: 'c', - displayName: 'Governance Review Board (GRB)', - tagType: 'other' - }, - { - username: 'OSYC', - displayName: 'Grant Eliezer', - tagType: 'user' - }, - { - username: 'MKCK', - displayName: 'Forest Brown', - tagType: 'user' - }, - { - username: 'PJEA', - displayName: 'Janae Stokes', - tagType: 'user' - } - ]; - }; + const fetchUsers = ({ query }: { query: string }) => { + return [ + { username: 'a', displayName: 'Admin lead', tagType: 'other' }, + { + username: 'b', + displayName: 'Governance Admin Team', + tagType: 'other' + }, + { + username: 'c', + displayName: 'Governance Review Board (GRB)', + tagType: 'other' + }, + { + username: 'OSYC', + displayName: 'Grant Eliezer', + tagType: 'user' + }, + { + username: 'MKCK', + displayName: 'Forest Brown', + tagType: 'user' + }, + { + username: 'PJEA', + displayName: 'Janae Stokes', + tagType: 'user' + } + ]; + }; - const editor = useEditor( - { + const editor = useEditor({ editable: editable && !disabled, editorProps: { attributes: { @@ -132,10 +127,16 @@ const MentionTextArea = ({ } }) ], - onUpdate: ({ editor: input }: any) => { - // Uses the form setter prop (Formik) for mutation input + onUpdate: ({ editor: input }) => { + const inputContent = input?.getHTML(); + const inputText = input?.getText(); + if (setFieldValue) { - setFieldValue('content', input?.getHTML()); + if (inputText === '') { + setFieldValue(''); + return; + } + setFieldValue(inputContent); } }, // Sets a alert of a mention is selected, and users/teams will be emailed @@ -143,30 +144,28 @@ const MentionTextArea = ({ setTagAlert(!!getMentions(input?.getJSON()).length); }, content: initialContent - }, - [initialContent, disabled] - ); + }); - return ( - <> - + return ( + <> + - {tagAlert && editable && ( - - {/* t() */} - When you save your discussion, the selected team(s) and individual(s) - will be notified via email. - - )} - - ); -}; + {tagAlert && editable && ( + + {t('general.alerts.saveDiscussion')} + + )} + + ); + } +); export default MentionTextArea; diff --git a/src/data/mock/discussions.ts b/src/data/mock/discussions.ts index a4b759044e..a9a014e554 100644 --- a/src/data/mock/discussions.ts +++ b/src/data/mock/discussions.ts @@ -7,7 +7,7 @@ import { import { systemIntake } from './systemIntake'; import users from './users'; -const mockDiscussions = ( +export const mockDiscussions = ( systemIntakeID: string = systemIntake.id ): SystemIntakeGRBReviewDiscussionFragment[] => [ { @@ -62,4 +62,80 @@ const mockDiscussions = ( } ]; -export default mockDiscussions; +export const mockDiscussionsWithoutReplies = ( + systemIntakeID: string = systemIntake.id +): SystemIntakeGRBReviewDiscussionFragment[] => [ + { + __typename: 'SystemIntakeGRBReviewDiscussion', + initialPost: { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: 'd6cd88e2-d330-4b7c-9006-d6cf95b775e9', + content: '

This is a discussion without replies.

', + votingRole: SystemIntakeGRBReviewerVotingRole.NON_VOTING, + grbRole: SystemIntakeGRBReviewerRole.CCIIO_REP, + createdByUserAccount: { + __typename: 'UserAccount', + id: '3f750a9d-a2a2-414f-a013-59554ed32c75', + commonName: users[2].commonName + }, + systemIntakeID, + createdAt: '2024-11-18T10:00:00.368862Z' + }, + replies: [] + }, + { + __typename: 'SystemIntakeGRBReviewDiscussion', + initialPost: { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: '372bf1c0-3f33-4046-a973-bc063f39dc59', + content: '

This is another discussion without replies.

', + votingRole: SystemIntakeGRBReviewerVotingRole.VOTING, + grbRole: SystemIntakeGRBReviewerRole.CO_CHAIR_HCA, + createdByUserAccount: { + __typename: 'UserAccount', + id: '32c29aac-e20c-4fce-9ecb-8eb2f4c87e5f', + commonName: users[9].commonName + }, + systemIntakeID, + createdAt: '2024-11-17T10:00:00.368862Z' + }, + replies: [] + }, + { + __typename: 'SystemIntakeGRBReviewDiscussion', + initialPost: { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: '39823b79-987d-4f81-9fcb-ae45f4c5bfeb', + content: '

This is a third discussion without replies.

', + votingRole: SystemIntakeGRBReviewerVotingRole.VOTING, + grbRole: SystemIntakeGRBReviewerRole.QIO_REP, + createdByUserAccount: { + __typename: 'UserAccount', + id: 'a9628365-16a5-49bf-9acc-7dbdade7288f', + commonName: users[3].commonName + }, + systemIntakeID, + createdAt: '2024-11-17T9:00:00.368862Z' + }, + replies: [] + }, + { + __typename: 'SystemIntakeGRBReviewDiscussion', + initialPost: { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: 'df00daf2-f666-4014-98d3-fd28630fe996', + content: + '

This discussion without replies should not show up until list is expanded.

', + votingRole: SystemIntakeGRBReviewerVotingRole.ALTERNATE, + grbRole: SystemIntakeGRBReviewerRole.FED_ADMIN_BDG_CHAIR, + createdByUserAccount: { + __typename: 'UserAccount', + id: 'c7eefa37-b917-4fa3-8fc0-b86fa5de7df2', + commonName: users[8].commonName + }, + systemIntakeID, + createdAt: '2024-11-17T9:30:00.368862Z' + }, + replies: [] + } +]; diff --git a/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionPost.ts b/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionPost.ts new file mode 100644 index 0000000000..c9d9ff527c --- /dev/null +++ b/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionPost.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export default gql(/* GraphQL */ ` + mutation CreateSystemIntakeGRBDiscussionPost( + $input: createSystemIntakeGRBDiscussionPostInput! + ) { + createSystemIntakeGRBDiscussionPost(input: $input) { + id + } + } +`); diff --git a/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionReply.ts b/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionReply.ts new file mode 100644 index 0000000000..9bd64fe718 --- /dev/null +++ b/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionReply.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export default gql(/* GraphQL */ ` + mutation CreateSystemIntakeGRBDiscussionReply( + $input: createSystemIntakeGRBDiscussionReplyInput! + ) { + createSystemIntakeGRBDiscussionReply(input: $input) { + id + } + } +`); diff --git a/src/gql/gen/graphql.ts b/src/gql/gen/graphql.ts index d577754a1e..4c56d4b99c 100644 --- a/src/gql/gen/graphql.ts +++ b/src/gql/gen/graphql.ts @@ -3228,6 +3228,20 @@ export type CreateSystemIntakeGRBDiscussionReplyInput = { initialPostID: Scalars['UUID']['input']; }; +export type CreateSystemIntakeGRBDiscussionPostMutationVariables = Exact<{ + input: CreateSystemIntakeGRBDiscussionPostInput; +}>; + + +export type CreateSystemIntakeGRBDiscussionPostMutation = { __typename: 'Mutation', createSystemIntakeGRBDiscussionPost?: { __typename: 'SystemIntakeGRBReviewDiscussionPost', id: UUID } | null }; + +export type CreateSystemIntakeGRBDiscussionReplyMutationVariables = Exact<{ + input: CreateSystemIntakeGRBDiscussionReplyInput; +}>; + + +export type CreateSystemIntakeGRBDiscussionReplyMutation = { __typename: 'Mutation', createSystemIntakeGRBDiscussionReply?: { __typename: 'SystemIntakeGRBReviewDiscussionPost', id: UUID } | null }; + export type CreateSystemIntakeGRBReviewersMutationVariables = Exact<{ input: CreateSystemIntakeGRBReviewersInput; }>; @@ -3596,6 +3610,72 @@ export const TRBGuidanceLetterFragmentDoc = gql` modifiedAt } ${TRBGuidanceLetterInsightFragmentDoc}`; +export const CreateSystemIntakeGRBDiscussionPostDocument = gql` + mutation CreateSystemIntakeGRBDiscussionPost($input: createSystemIntakeGRBDiscussionPostInput!) { + createSystemIntakeGRBDiscussionPost(input: $input) { + id + } +} + `; +export type CreateSystemIntakeGRBDiscussionPostMutationFn = Apollo.MutationFunction; + +/** + * __useCreateSystemIntakeGRBDiscussionPostMutation__ + * + * To run a mutation, you first call `useCreateSystemIntakeGRBDiscussionPostMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateSystemIntakeGRBDiscussionPostMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createSystemIntakeGrbDiscussionPostMutation, { data, loading, error }] = useCreateSystemIntakeGRBDiscussionPostMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateSystemIntakeGRBDiscussionPostMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateSystemIntakeGRBDiscussionPostDocument, options); + } +export type CreateSystemIntakeGRBDiscussionPostMutationHookResult = ReturnType; +export type CreateSystemIntakeGRBDiscussionPostMutationResult = Apollo.MutationResult; +export type CreateSystemIntakeGRBDiscussionPostMutationOptions = Apollo.BaseMutationOptions; +export const CreateSystemIntakeGRBDiscussionReplyDocument = gql` + mutation CreateSystemIntakeGRBDiscussionReply($input: createSystemIntakeGRBDiscussionReplyInput!) { + createSystemIntakeGRBDiscussionReply(input: $input) { + id + } +} + `; +export type CreateSystemIntakeGRBDiscussionReplyMutationFn = Apollo.MutationFunction; + +/** + * __useCreateSystemIntakeGRBDiscussionReplyMutation__ + * + * To run a mutation, you first call `useCreateSystemIntakeGRBDiscussionReplyMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateSystemIntakeGRBDiscussionReplyMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createSystemIntakeGrbDiscussionReplyMutation, { data, loading, error }] = useCreateSystemIntakeGRBDiscussionReplyMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateSystemIntakeGRBDiscussionReplyMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateSystemIntakeGRBDiscussionReplyDocument, options); + } +export type CreateSystemIntakeGRBDiscussionReplyMutationHookResult = ReturnType; +export type CreateSystemIntakeGRBDiscussionReplyMutationResult = Apollo.MutationResult; +export type CreateSystemIntakeGRBDiscussionReplyMutationOptions = Apollo.BaseMutationOptions; export const CreateSystemIntakeGRBReviewersDocument = gql` mutation CreateSystemIntakeGRBReviewers($input: CreateSystemIntakeGRBReviewersInput!) { createSystemIntakeGRBReviewers(input: $input) { @@ -4894,6 +4974,8 @@ export const TypedTRBAdminNoteGuidanceLetterCategoryDataFragmentDoc = {"kind":"D export const TypedTRBAdminNoteFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TRBAdminNote"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TRBAdminNote"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isArchived"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"noteText"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commonName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"categorySpecificData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TRBAdminNoteInitialRequestFormCategoryData"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TRBAdminNoteInitialRequestFormCategoryData"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TRBAdminNoteSupportingDocumentsCategoryData"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TRBAdminNoteSupportingDocumentsCategoryData"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TRBAdminNoteGuidanceLetterCategoryData"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TRBAdminNoteGuidanceLetterCategoryData"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TRBAdminNoteInitialRequestFormCategoryData"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TRBAdminNoteInitialRequestFormCategoryData"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appliesToBasicRequestDetails"}},{"kind":"Field","name":{"kind":"Name","value":"appliesToSubjectAreas"}},{"kind":"Field","name":{"kind":"Name","value":"appliesToAttendees"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TRBAdminNoteSupportingDocumentsCategoryData"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TRBAdminNoteSupportingDocumentsCategoryData"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fileName"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TRBAdminNoteGuidanceLetterCategoryData"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TRBAdminNoteGuidanceLetterCategoryData"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appliesToMeetingSummary"}},{"kind":"Field","name":{"kind":"Name","value":"appliesToNextSteps"}},{"kind":"Field","name":{"kind":"Name","value":"insights"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}}]}}]} as unknown as DocumentNode; export const TypedTRBGuidanceLetterInsightFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TRBGuidanceLetterInsight"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TRBGuidanceLetterRecommendation"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"recommendation"}},{"kind":"Field","name":{"kind":"Name","value":"links"}}]}}]} as unknown as DocumentNode; export const TypedTRBGuidanceLetterFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TRBGuidanceLetter"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TRBGuidanceLetter"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"meetingSummary"}},{"kind":"Field","name":{"kind":"Name","value":"nextSteps"}},{"kind":"Field","name":{"kind":"Name","value":"isFollowupRecommended"}},{"kind":"Field","name":{"kind":"Name","value":"dateSent"}},{"kind":"Field","name":{"kind":"Name","value":"followupPoint"}},{"kind":"Field","name":{"kind":"Name","value":"insights"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TRBGuidanceLetterInsight"}}]}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"euaUserId"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"modifiedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TRBGuidanceLetterInsight"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TRBGuidanceLetterRecommendation"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"recommendation"}},{"kind":"Field","name":{"kind":"Name","value":"links"}}]}}]} as unknown as DocumentNode; +export const TypedCreateSystemIntakeGRBDiscussionPostDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateSystemIntakeGRBDiscussionPost"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"createSystemIntakeGRBDiscussionPostInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createSystemIntakeGRBDiscussionPost"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; +export const TypedCreateSystemIntakeGRBDiscussionReplyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateSystemIntakeGRBDiscussionReply"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"createSystemIntakeGRBDiscussionReplyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createSystemIntakeGRBDiscussionReply"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const TypedCreateSystemIntakeGRBReviewersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateSystemIntakeGRBReviewers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateSystemIntakeGRBReviewersInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createSystemIntakeGRBReviewers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"reviewers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"userAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; export const TypedDeleteSystemIntakeGRBReviewerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteSystemIntakeGRBReviewer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteSystemIntakeGRBReviewerInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteSystemIntakeGRBReviewer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const TypedgetGRBReviewersComparisonsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getGRBReviewersComparisons"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"compareGRBReviewersByIntakeID"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requestName"}},{"kind":"Field","name":{"kind":"Name","value":"reviewers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"userAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isCurrentReviewer"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/src/i18n/en-US/discussions.ts b/src/i18n/en-US/discussions.ts index ea11bfad69..3e6f6b7252 100644 --- a/src/i18n/en-US/discussions.ts +++ b/src/i18n/en-US/discussions.ts @@ -7,6 +7,7 @@ const discussions = { general: { cancel: 'Cancel', // TODO: this is in other i18n files, move to general.ts? label: 'Discussions', + discussion: 'Discussion', mostRecentActivity: 'Most recent activity', newTopics: '{{count}} new discussion topic', newTopics_plural: '{{count}} new discussion topics', @@ -23,16 +24,22 @@ const discussions = { repliesCount_plural: '{{count}} replies', reply: 'Reply', lastReply: 'Last reply {{date}} at {{time}}', - saveDiscussion: 'Save discussion', + hideReplies: 'Hide replies', + showReplies: 'Show replies', - startDiscussion: 'Start a new discussion', + startNewDiscussion: 'Start a new discussion', view: 'View', // TODO: this is in other i18n files, move to general.ts? viewDiscussionBoard: 'View discussion board', + viewMore: 'View more {{type}}', + viewLess: 'View less {{type}}', + alerts: { - noDiscussions: + noDiscussionsStarted: 'There are no discussions yet. When a discussion topic is started, it will appear here.', + noDiscussionsRepliedTo: + 'There are no discussions yet. When a discussion topic is replied to, it will appear here.', noDiscussionsStartButton: 'There are not yet any discussions. .', replyError: @@ -46,15 +53,20 @@ const discussions = { 'You have successfully added to the discussion board.' }, - contribute: { - // description: - // 'Have a question or comment that you want to discuss internally with the Governance Admin Team or other Governance Review Board (GRB) members involved in this request? Start a discussion and you’ll be notified when they reply.', + startDiscussion: { + heading: 'Start a discussion', description: - 'Have a question or comment that you want to discuss internally with the {{groupNames}} members involved in this request? Start a discussion and you’ll be notified when they reply.', - newTopicInstructions: 'Type your question or discussion topic', - replyInstructions: 'Type your reply', - taggingHelpText: - 'To tag an individual or team, type "@" and select the individual or group you wish to notify. You may begin typing the group name or individual’s name if you do not see it in the list. In this discussion board, you are only able to tag GRB reviewers or Governance Admin Team members.' + 'Have a question or comment that you want to discuss internally with the Governance Admin Team or other Governance Review Board (GRB) members involved in this request? Start a discussion and you’ll be notified when they reply.' + // description: + // 'Have a question or comment that you want to discuss internally with the {{groupNames}} members involved in this request? Start a discussion and you’ll be notified when they reply.', + }, + + discussionForm: { + contentLabel_discussion: 'Type your question or discussion topic', + contentLabel_reply: 'Type your reply', + helpText: + 'To tag an individual or team, type "@" and select the individual or group you wish to notify. You may begin typing the group name or individual’s name if you do not see it in the list. In this discussion board, you are only able to tag GRB reviewers or Governance Admin Team members.', + save: 'Save {{type}}' }, usageTips: { @@ -75,10 +87,10 @@ const discussions = { internal: { label: 'Internal GRB discussion board', // TODO: enum translation? visibilityRestricted: 'Visibility restricted', - // description: - // 'Use this discussion board to ask questions or have dicussions with the Governance Admin Team and other Governance Review Board (GRB) members. The conversations here are not visible to the Project team.' description: - 'Use this discussion board to ask questions or have dicussions with the {{groupNames}} members. The conversations here are not visible to the Project team.' + 'Use this discussion board to ask questions or have dicussions with the Governance Admin Team and other Governance Review Board (GRB) members. The conversations here are not visible to the Project team.' + // description: + // 'Use this discussion board to ask questions or have dicussions with the {{groupNames}} members. The conversations here are not visible to the Project team.' } } }; diff --git a/src/types/discussions.ts b/src/types/discussions.ts new file mode 100644 index 0000000000..abbd44010c --- /dev/null +++ b/src/types/discussions.ts @@ -0,0 +1,5 @@ +import { AlertProps } from 'components/shared/Alert'; + +export type DiscussionAlert = + | (Omit & { message: string }) + | null; diff --git a/src/validations/discussionSchema.ts b/src/validations/discussionSchema.ts new file mode 100644 index 0000000000..ac3b244caa --- /dev/null +++ b/src/validations/discussionSchema.ts @@ -0,0 +1,8 @@ +import i18next from 'i18next'; +import * as Yup from 'yup'; + +const discussionSchema = Yup.object().shape({ + content: Yup.string().required(i18next.t('form:inputError.fillBlank')) +}); + +export default discussionSchema; diff --git a/src/views/DiscussionBoard/Discussion.test.tsx b/src/views/DiscussionBoard/Discussion.test.tsx new file mode 100644 index 0000000000..325dc89fbb --- /dev/null +++ b/src/views/DiscussionBoard/Discussion.test.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + SystemIntakeGRBReviewDiscussionFragment, + SystemIntakeGRBReviewerRole, + SystemIntakeGRBReviewerVotingRole +} from 'gql/gen/graphql'; +import i18next from 'i18next'; + +import { mockDiscussions } from 'data/mock/discussions'; +import users from 'data/mock/users'; +import VerboseMockedProvider from 'utils/testing/VerboseMockedProvider'; + +import Discussion from './Discussion'; + +describe('Discussion component', () => { + const discussions = mockDiscussions(); + const { systemIntakeID } = discussions[0].initialPost; + + it('renders the discussion', () => { + const [discussion] = discussions; + + render( + + + + ); + + expect( + screen.getByRole('heading', { level: 1, name: 'Discussion' }) + ).toBeInTheDocument(); + + expect( + screen.getByText(discussion.initialPost.createdByUserAccount.commonName) + ).toBeInTheDocument(); + + expect( + screen.getByRole('heading', { + level: 4, + name: i18next.t('discussions:general.repliesCount', { + count: discussion.replies.length + }) + }) + ).toBeInTheDocument(); + + // Should not show view more replies button with only two replies + expect( + screen.queryByRole('button', { name: 'View more replies' }) + ).toBeNull(); + }); + + it('renders the replies', () => { + const discussion: SystemIntakeGRBReviewDiscussionFragment = { + ...discussions[0], + replies: [ + ...discussions[0].replies, + { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: '49eacd80-cb13-46f3-8d74-def73e15a71e', + content: '

This is a reply.

', + votingRole: SystemIntakeGRBReviewerVotingRole.NON_VOTING, + grbRole: SystemIntakeGRBReviewerRole.PROGRAM_OPERATIONS_BDG_CHAIR, + createdByUserAccount: { + __typename: 'UserAccount', + id: '859cd654-9fff-48d8-a9aa-a8a6491922b1', + commonName: users[4].commonName + }, + systemIntakeID, + createdAt: '2024-11-13T10:00:00.368862Z' + }, + { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: '6d194b86-13fc-4d21-87e1-70dfb1514cf7', + content: '

This is a reply.

', + votingRole: SystemIntakeGRBReviewerVotingRole.VOTING, + grbRole: SystemIntakeGRBReviewerRole.CO_CHAIR_CIO, + createdByUserAccount: { + __typename: 'UserAccount', + id: '25d5e6cd-0cb9-4db8-aa9a-09458400ee7f', + commonName: users[8].commonName + }, + systemIntakeID, + createdAt: '2024-11-13T10:00:00.368862Z' + }, + { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: '46e23f3f-37f5-4f50-87da-cf849df181d7', + content: '

This is a reply.

', + votingRole: SystemIntakeGRBReviewerVotingRole.ALTERNATE, + grbRole: SystemIntakeGRBReviewerRole.CCIIO_REP, + createdByUserAccount: { + __typename: 'UserAccount', + id: 'b5630d2b-89a0-4e7e-afd8-e2aa65f23823', + commonName: users[1].commonName + }, + systemIntakeID, + createdAt: '2024-11-13T10:00:00.368862Z' + } + ] + }; + + render( + + + + ); + + // Toggle view more replies + + const repliesList = screen.getByTestId('discussionsList'); + + expect(within(repliesList).getAllByRole('listitem')).toHaveLength(4); + + const viewMoreButton = screen.getByRole('button', { + name: 'View more replies' + }); + + userEvent.click(viewMoreButton); + + expect(within(repliesList).getAllByRole('listitem')).toHaveLength( + discussion.replies.length + ); + + expect( + screen.getByRole('button', { name: 'View less replies' }) + ).toBeInTheDocument(); + + // Toggle hide replies + + const hideRepliesButton = screen.getByRole('button', { + name: 'Hide replies' + }); + + userEvent.click(hideRepliesButton); + + expect(screen.queryByTestId('discussionList')).toBeNull(); + + expect( + screen.getByRole('button', { name: 'Show replies' }) + ).toBeInTheDocument(); + }); +}); diff --git a/src/views/DiscussionBoard/Discussion.tsx b/src/views/DiscussionBoard/Discussion.tsx new file mode 100644 index 0000000000..f0369504bb --- /dev/null +++ b/src/views/DiscussionBoard/Discussion.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icon } from '@trussworks/react-uswds'; +import { SystemIntakeGRBReviewDiscussionFragment } from 'gql/gen/graphql'; + +import IconButton from 'components/shared/IconButton'; +import { DiscussionAlert } from 'types/discussions'; +import { NotFoundPartial } from 'views/NotFound'; + +import DiscussionForm from './components/DiscussionForm'; +import DiscussionPost from './components/DiscussionPost'; +import DiscussionsList from './components/DiscussionsList'; + +type DiscussionProps = { + discussion: SystemIntakeGRBReviewDiscussionFragment | null; + closeModal: () => void; + setDiscussionAlert: (discussionAlert: DiscussionAlert) => void; +}; + +/** + * Single discussion post view + * + * Displays discussion, replies, and form to reply to discussion post + */ +const Discussion = ({ + discussion, + closeModal, + setDiscussionAlert +}: DiscussionProps) => { + const { t } = useTranslation('discussions'); + const [showReplies, setShowReplies] = useState(true); + + if (!discussion) return ; + + const { initialPost, replies } = discussion; + + return ( +
+

{t('general.discussion')}

+ + + + {replies.length > 0 && ( + <> +
+

+ {t('general.repliesCount', { count: replies.length })} +

+ setShowReplies(!showReplies)} + icon={showReplies ? : } + iconPosition="after" + unstyled + > + {showReplies + ? t('general.hideReplies') + : t('general.showReplies')} + +
+ + {showReplies && ( + + {replies.map(reply => ( +
  • + +
  • + ))} +
    + )} + + )} + +

    {t('general.reply')}

    + +
    + ); +}; + +export default Discussion; diff --git a/src/views/DiscussionBoard/DiscussionModalWrapper.tsx b/src/views/DiscussionBoard/DiscussionModalWrapper.tsx index 2273b81fe0..91cec9413a 100644 --- a/src/views/DiscussionBoard/DiscussionModalWrapper.tsx +++ b/src/views/DiscussionBoard/DiscussionModalWrapper.tsx @@ -22,10 +22,10 @@ const DiscussionModalWrapper = ({ ariaLabel={t('ariaLabel')} closeModal={closeModal} isOpen={isOpen} - modalHeading={t('modalHeading')} + modalHeading={t('governanceReviewBoard.internal.label')} testid="discussion-modal" > - + {children} diff --git a/src/views/DiscussionBoard/DiscussionPost/index.scss b/src/views/DiscussionBoard/DiscussionPost/index.scss deleted file mode 100644 index e70eecad3a..0000000000 --- a/src/views/DiscussionBoard/DiscussionPost/index.scss +++ /dev/null @@ -1,9 +0,0 @@ -.easi-discussion-post { - &__header { - justify-content: space-between; - } - - &__content * { - line-height: 1.6 !important; - } -} diff --git a/src/views/DiscussionBoard/StartDiscussion.tsx b/src/views/DiscussionBoard/StartDiscussion.tsx new file mode 100644 index 0000000000..9ae408c591 --- /dev/null +++ b/src/views/DiscussionBoard/StartDiscussion.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { DiscussionAlert } from 'types/discussions'; + +import DiscussionForm from './components/DiscussionForm'; + +type StartDiscussionProps = { + systemIntakeID: string; + closeModal: () => void; + setDiscussionAlert: (discussionAlert: DiscussionAlert) => void; +}; + +/** + * Form to start new discussion post + */ +const StartDiscussion = ({ + systemIntakeID, + closeModal, + setDiscussionAlert +}: StartDiscussionProps) => { + const { t } = useTranslation('discussions'); + + return ( +
    +

    + {t('general.startDiscussion.heading')} +

    +

    + {t('general.startDiscussion.description')} +

    + + +
    + ); +}; + +export default StartDiscussion; diff --git a/src/views/DiscussionBoard/ViewDiscussions.test.tsx b/src/views/DiscussionBoard/ViewDiscussions.test.tsx new file mode 100644 index 0000000000..5a4a5a05ec --- /dev/null +++ b/src/views/DiscussionBoard/ViewDiscussions.test.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import i18next from 'i18next'; + +import { + mockDiscussions, + mockDiscussionsWithoutReplies +} from 'data/mock/discussions'; + +import ViewDiscussions from './ViewDiscussions'; + +describe('ViewDiscussions component', () => { + const noNewDiscussionsText = i18next.t( + 'discussions:general.alerts.noDiscussionsStarted' + ); + + const noDiscussionsWithRepliesText = i18next.t( + 'discussions:general.alerts.noDiscussionsRepliedTo' + ); + + it('renders the component', () => { + render(); + + expect( + screen.getByRole('heading', { + level: 1, + name: 'Internal GRB discussion board' + }) + ).toBeInTheDocument(); + + expect( + screen.getByRole('heading', { level: 2, name: 'Discussions' }) + ).toBeInTheDocument(); + + expect( + screen.getByRole('button', { name: 'Start a new discussion' }) + ).toBeInTheDocument(); + + expect(screen.getByTestId('accordion')).toBeInTheDocument(); + }); + + it('renders alerts for no discussion posts', () => { + render(); + + expect(screen.getByText(noNewDiscussionsText)).toBeInTheDocument(); + + expect(screen.getByText(noDiscussionsWithRepliesText)).toBeInTheDocument(); + }); + + it('renders accordion components with formatted discussion lists', () => { + const grbDiscussions = [ + ...mockDiscussions(), + ...mockDiscussionsWithoutReplies() + ]; + + const discussionsWithReplies = grbDiscussions.filter( + discussion => discussion.replies.length > 0 + ); + const discussionsWithoutReplies = grbDiscussions.filter( + discussion => discussion.replies.length === 0 + ); + + render(); + + /* Discussions with replies */ + + expect( + screen.getByRole('heading', { + level: 4, + name: i18next.t('discussions:general.discussedTopics', { + count: discussionsWithReplies.length + }) + }) + ).toBeInTheDocument(); + + /* New discussions without replies */ + + expect( + screen.getByRole('heading', { + level: 4, + name: i18next.t('discussions:general.newTopics', { + count: discussionsWithoutReplies.length + }) + }) + ).toBeInTheDocument(); + + const newDiscussionsAccordion = screen.getByTestId( + 'accordionItem_grbDiscussionsNew' + ); + + const newDiscussionListItems = within(newDiscussionsAccordion).getAllByRole( + 'listitem' + ); + + // Unexpanded list should display three items + expect(newDiscussionListItems).toHaveLength(3); + + const expandButton = within(newDiscussionsAccordion).getByRole('button', { + name: 'View more discussions' + }); + + userEvent.click(expandButton); + + const expandedNewDiscussionListItems = within( + newDiscussionsAccordion + ).getAllByRole('listitem'); + + // Expanded list should display all items + expect(expandedNewDiscussionListItems).toHaveLength( + discussionsWithoutReplies.length + ); + }); +}); diff --git a/src/views/DiscussionBoard/ViewDiscussions.tsx b/src/views/DiscussionBoard/ViewDiscussions.tsx new file mode 100644 index 0000000000..14e4f92845 --- /dev/null +++ b/src/views/DiscussionBoard/ViewDiscussions.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Accordion, Icon } from '@trussworks/react-uswds'; +import { SystemIntakeGRBReviewDiscussionFragment } from 'gql/gen/graphql'; + +import Alert from 'components/shared/Alert'; +import IconButton from 'components/shared/IconButton'; + +import DiscussionPost from './components/DiscussionPost'; +import DiscussionsList from './components/DiscussionsList'; + +type ViewDiscussionsProps = { + grbDiscussions: SystemIntakeGRBReviewDiscussionFragment[]; +}; + +/** + * List of discussions view + * + * Displays list of all discussions + * with links to start a new discussion or reply to existing discussions + */ +const ViewDiscussions = ({ grbDiscussions }: ViewDiscussionsProps) => { + const { t } = useTranslation('discussions'); + + const discussionsWithoutReplies: SystemIntakeGRBReviewDiscussionFragment[] = + grbDiscussions.filter(discussion => discussion.replies.length === 0); + + const discussionsWithReplies: SystemIntakeGRBReviewDiscussionFragment[] = + grbDiscussions.filter(discussion => discussion.replies.length > 0); + + return ( +
    +

    + {t('governanceReviewBoard.internal.label')} +

    +

    + {t('governanceReviewBoard.internal.description')} +

    + +

    {t('general.label')}

    + null} + icon={} + unstyled + > + {t('general.startNewDiscussion')} + + + 0 ? ( + <> + + {discussionsWithoutReplies.map((discussion, index) => ( +
  • + +
  • + ))} +
    + + ) : ( + + {t('general.alerts.noDiscussionsStarted')} + + ) + }, + { + id: 'grbDiscussionsWithReplies', + title: t('general.discussedTopics', { + count: discussionsWithReplies.length + }), + expanded: true, + headingLevel: 'h4', + content: + discussionsWithReplies.length > 0 ? ( + <> + + {discussionsWithReplies.map((discussion, index) => ( +
  • + +
  • + ))} +
    + + ) : ( + + {t('general.alerts.noDiscussionsRepliedTo')} + + ) + } + ]} + /> +
    + ); +}; + +export default ViewDiscussions; diff --git a/src/views/DiscussionBoard/components/DiscussionForm.tsx b/src/views/DiscussionBoard/components/DiscussionForm.tsx new file mode 100644 index 0000000000..b0dcba4fd5 --- /dev/null +++ b/src/views/DiscussionBoard/components/DiscussionForm.tsx @@ -0,0 +1,173 @@ +import React from 'react'; +import { Controller } from 'react-hook-form'; +import { Trans, useTranslation } from 'react-i18next'; +import { ErrorMessage } from '@hookform/error-message'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { Button, ButtonGroup, Form, FormGroup } from '@trussworks/react-uswds'; +import { + useCreateSystemIntakeGRBDiscussionPostMutation, + useCreateSystemIntakeGRBDiscussionReplyMutation +} from 'gql/gen/graphql'; + +import { useEasiForm } from 'components/EasiForm'; +import MentionTextArea from 'components/MentionTextArea'; +import FieldErrorMsg from 'components/shared/FieldErrorMsg'; +import HelpText from 'components/shared/HelpText'; +import Label from 'components/shared/Label'; +import RequiredAsterisk from 'components/shared/RequiredAsterisk'; +import { DiscussionAlert } from 'types/discussions'; +import discussionSchema from 'validations/discussionSchema'; + +type DiscussionContent = { + content: string; +}; + +interface DiscussionFormProps { + setDiscussionAlert: (discussionAlert: DiscussionAlert) => void; + closeModal: () => void; +} + +interface DiscussionProps extends DiscussionFormProps { + type: 'discussion'; + systemIntakeID: string; +} + +interface ReplyProps extends DiscussionFormProps { + type: 'reply'; + initialPostID: string; +} + +/** + * Form for adding a discussion post or responding to a discussion + * within the discussion board + */ +const DiscussionForm = ({ + type, + closeModal, + setDiscussionAlert, + ...mutationProps +}: DiscussionProps | ReplyProps) => { + const { t } = useTranslation('discussions'); + + const [mutateDiscussion] = useCreateSystemIntakeGRBDiscussionPostMutation(); + + const [mutateReply] = useCreateSystemIntakeGRBDiscussionReplyMutation(); + + const { + control, + handleSubmit, + reset, + formState: { isValid, errors } + } = useEasiForm({ + resolver: yupResolver(discussionSchema) + }); + + const createDiscussion = handleSubmit(({ content }) => { + if ('systemIntakeID' in mutationProps) { + mutateDiscussion({ + variables: { + input: { + systemIntakeID: mutationProps.systemIntakeID, + content + } + } + }) + .then(() => { + setDiscussionAlert({ + message: t('general.alerts.startDiscussionSuccess'), + type: 'success' + }); + }) + .catch(e => { + setDiscussionAlert({ + message: t('general.alerts.startDiscussionError'), + type: 'error' + }); + }); + + // TODO: Go back to discussion board view + } + }); + + const createReply = handleSubmit(({ content }) => { + if ('initialPostID' in mutationProps) { + mutateReply({ + variables: { + input: { + initialPostID: mutationProps.initialPostID, + content + } + } + }) + .then(() => { + // Reset field values + reset(); + + setDiscussionAlert({ + message: t('general.alerts.replySuccess'), + type: 'success' + }); + }) + .catch(e => { + setDiscussionAlert({ + message: t('general.alerts.replyError'), + type: 'error' + }); + }); + } + }); + + return ( +
    +

    + }} + /> +

    + + + + + {t('general.discussionForm.helpText')} + + + + + ( + field.onChange(value)} + /> + )} + /> + + + + + + +
    + ); +}; + +export default DiscussionForm; diff --git a/src/views/DiscussionBoard/components/DiscussionPost/index.scss b/src/views/DiscussionBoard/components/DiscussionPost/index.scss new file mode 100644 index 0000000000..c94f276f98 --- /dev/null +++ b/src/views/DiscussionBoard/components/DiscussionPost/index.scss @@ -0,0 +1,16 @@ +.easi-discussion-post { + &__header { + justify-content: space-between; + } + + &__content * { + line-height: 1.6 !important; + } + + .easi-discussion-avatar { + box-sizing: content-box; + border-bottom: 4px solid white; + border-top: 4px solid white; + margin-top: -4px; + } +} diff --git a/src/views/DiscussionBoard/DiscussionPost/index.test.tsx b/src/views/DiscussionBoard/components/DiscussionPost/index.test.tsx similarity index 97% rename from src/views/DiscussionBoard/DiscussionPost/index.test.tsx rename to src/views/DiscussionBoard/components/DiscussionPost/index.test.tsx index 07b4242916..320e78f7a6 100644 --- a/src/views/DiscussionBoard/DiscussionPost/index.test.tsx +++ b/src/views/DiscussionBoard/components/DiscussionPost/index.test.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react'; import { SystemIntakeGRBReviewDiscussionFragment } from 'gql/gen/graphql'; import i18next from 'i18next'; -import mockDiscussions from 'data/mock/discussions'; +import { mockDiscussions } from 'data/mock/discussions'; import { getRelativeDate } from 'utils/date'; import DiscussionPost from '.'; diff --git a/src/views/DiscussionBoard/DiscussionPost/index.tsx b/src/views/DiscussionBoard/components/DiscussionPost/index.tsx similarity index 95% rename from src/views/DiscussionBoard/DiscussionPost/index.tsx rename to src/views/DiscussionBoard/components/DiscussionPost/index.tsx index a005fe628f..1ec8288dee 100644 --- a/src/views/DiscussionBoard/DiscussionPost/index.tsx +++ b/src/views/DiscussionBoard/components/DiscussionPost/index.tsx @@ -61,9 +61,12 @@ const DiscussionPost = ({ replies, ...initialPost }: DiscussionPostProps) => { }, [replies, t]); return ( -
    +
    - +
    diff --git a/src/views/DiscussionBoard/components/DiscussionsList.tsx b/src/views/DiscussionBoard/components/DiscussionsList.tsx new file mode 100644 index 0000000000..5b208b4507 --- /dev/null +++ b/src/views/DiscussionBoard/components/DiscussionsList.tsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@trussworks/react-uswds'; +import classNames from 'classnames'; + +type DiscussionsListProps = { + type: 'discussions' | 'replies'; + children: React.ReactNodeArray; + initialCount: number; + /** className for
      wrapper element */ + className?: string; +}; + +/** + * Truncated list wrapper component for discussion posts + * + * Children should be formatted as `
    • ` + */ +const DiscussionsList = ({ + type, + children, + initialCount, + className +}: DiscussionsListProps) => { + const { t } = useTranslation('discussions'); + + const [isExpanded, setExpanded] = useState(false); + + const defaultContent = children + .filter(child => child) // Filter out conditional children + .flat() + .slice(0, initialCount); + + const expandedContent = children + .filter(child => child) // Filter out conditional children + .flat() + .slice(initialCount); + + /** Discussions type list has bottom border when toggle button is shown */ + const hasBorder = type === 'discussions' && children.length > initialCount; + + return ( + <> +
        + {defaultContent} + + {isExpanded && expandedContent} +
      + + {expandedContent.length > 0 && ( + + )} + + ); +}; + +export default DiscussionsList; diff --git a/src/views/DiscussionBoard/index.scss b/src/views/DiscussionBoard/index.scss index a46b6df997..ae5d4c6be5 100644 --- a/src/views/DiscussionBoard/index.scss +++ b/src/views/DiscussionBoard/index.scss @@ -2,36 +2,38 @@ @use 'viewports' as *; .easi-discussions { - &__body { - padding: 4rem 2rem 2rem; - } - - &__connected { - border-left: .25rem solid color('base-lightest'); - margin-left: .9rem; - padding-left: 1.4rem; - margin-top: 0.5rem; - } + .discussions-list { + .usa-accordion__content { + padding: 0 !important; - &__not-connected { - padding-left: 2.6rem; + ul li { + &:not(:last-child) { + border-bottom: 1px solid color('base-light'); + } + } + } } - &__single-discussion:last-of-type { - margin-bottom: -1rem; - margin-top: -.5rem; - } -} - -.no-button > .usa-accordion__heading > .usa-accordion__button { - background-size: 0rem !important; -} + .discussion-replies-thread { + li:not(:last-child) { + .easi-discussion-post { + > div:first-child { + position: relative; -.discussion-accordion > .usa-accordion__content { - padding: 0rem 1rem; + &::before { + content: ""; + position: absolute; + height: 100%; + width: calc(50% + 2px); + border-right: 4px solid color('base-lightest'); + z-index: -1; + } + } - &:empty { - padding-top: 0; - padding-bottom: 0; + > div:last-child { + padding-bottom: 1.75rem; + } + } + } } } diff --git a/src/views/DiscussionBoard/index.tsx b/src/views/DiscussionBoard/index.tsx index 65e68ae9c6..8982036461 100644 --- a/src/views/DiscussionBoard/index.tsx +++ b/src/views/DiscussionBoard/index.tsx @@ -1,44 +1,67 @@ -import React from 'react'; -import { Button, ButtonGroup } from '@trussworks/react-uswds'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { SystemIntakeGRBReviewDiscussionFragment } from 'gql/gen/graphql'; -import MentionTextArea from '../../components/MentionTextArea'; +import Alert from 'components/shared/Alert'; +import { DiscussionAlert } from 'types/discussions'; +import Discussion from './Discussion'; import DiscussionModalWrapper from './DiscussionModalWrapper'; +// import ViewDiscussions from './ViewDiscussions'; +// import StartDiscussion from './StartDiscussion'; +import './index.scss'; + type DiscussionBoardProps = { + systemIntakeID: string; + grbDiscussions: SystemIntakeGRBReviewDiscussionFragment[]; isOpen: boolean; closeModal: () => void; - id: string; }; -function DiscussionBoard({ isOpen, closeModal, id }: DiscussionBoardProps) { +function DiscussionBoard({ + systemIntakeID, + grbDiscussions, + isOpen, + closeModal +}: DiscussionBoardProps) { + /** Discussion alert state for form success and error messages */ + const [discussionAlert, setDiscussionAlert] = useState(null); + + // Get the first discussion from the array for testing purposes + const activeDiscussion = grbDiscussions.length > 0 ? grbDiscussions[0] : null; + + // Reset discussionAlert when side panel is opened or closed + useEffect(() => { + setDiscussionAlert(null); + }, [setDiscussionAlert, isOpen]); + return ( - {/* Question */} -

      - Start a discussion -

      -

      - Have a question or comment that you want to discuss internally with the - Governance Admin Team or other Governance Review Board (GRB) members - involved in this request? Start a discussion and you’ll be notified when - they reply. -

      -
      - -
      - - - - + {discussionAlert && ( + + {discussionAlert.message} + + )} + {/* */} + + {/* */} + +
      ); } diff --git a/src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx b/src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx index 3a3155754b..e2e6384b4d 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx @@ -2,7 +2,8 @@ import React from 'react'; import { render, screen, within } from '@testing-library/react'; import { SystemIntakeGRBReviewDiscussionFragment } from 'gql/gen/graphql'; -import mockDiscussions from 'data/mock/discussions'; +import { mockDiscussions } from 'data/mock/discussions'; +import { systemIntake } from 'data/mock/systemIntake'; import Discussions from './Discussions'; @@ -15,7 +16,12 @@ const discussionWithoutReplies: SystemIntakeGRBReviewDiscussionFragment = { describe('Discussions', () => { it('renders 0 discussions without replies', () => { - render(); + render( + + ); expect( screen.getByRole('heading', { name: 'Most recent activity' }) @@ -31,7 +37,12 @@ describe('Discussions', () => { }); it('renders 1 discussion without replies', () => { - render(); + render( + + ); expect( screen.getByText('1 discussion without replies') @@ -45,7 +56,9 @@ describe('Discussions', () => { }); it('renders discussion board with no discussions', () => { - render(); + render( + + ); expect( screen.queryByRole('heading', { name: 'Most recent activity' }) diff --git a/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx b/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx index 3e2afe6847..169a014369 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx @@ -8,15 +8,20 @@ import Alert from 'components/shared/Alert'; import CollapsableLink from 'components/shared/CollapsableLink'; import IconButton from 'components/shared/IconButton'; import DiscussionBoard from 'views/DiscussionBoard'; -import DiscussionPost from 'views/DiscussionBoard/DiscussionPost'; +import DiscussionPost from 'views/DiscussionBoard/components/DiscussionPost'; type DiscussionsProps = { + systemIntakeID: string; grbDiscussions: SystemIntakeGRBReviewDiscussionFragment[]; className?: string; }; /** Displays recent discussions on GRB Review tab */ -const Discussions = ({ grbDiscussions, className }: DiscussionsProps) => { +const Discussions = ({ + systemIntakeID, + grbDiscussions, + className +}: DiscussionsProps) => { const { t } = useTranslation('discussions'); const [isDiscussionBoardOpen, setIsDiscussionBoardOpen] = @@ -32,9 +37,10 @@ const Discussions = ({ grbDiscussions, className }: DiscussionsProps) => { return ( <> setIsDiscussionBoardOpen(false)} - id="grb-discussion" />
      From 605e6747cec1c102c6583774c922d5101d0bf612 Mon Sep 17 00:00:00 2001 From: samoddball <156127704+samoddball@users.noreply.github.com> Date: Mon, 25 Nov 2024 09:03:19 -0700 Subject: [PATCH 09/22] Easi 4633/editor html (#2892) * starting with schema types * create type * some extra types and methods * add magic * pull from mint, gql * some cleanup, remove gql stuff that will be part of a different ticket * testing dummy route to test out tag parsing * remove what seemed to be extra sanitizations * clean up test code, still working on things * still testing, things looking good for ingestion * integrating dummy with real * cleanup similar struct * update postman call * add fields to postman call * postman * whoops - remove hardcoded sys intake id from postman * update tag types * remove unneeded * fix lint * update sanitization policy and use it * leave classs as "mention" to match UI * postman * remove commented out item * unused * return only HTML, skip tags and extra types * postman * remove unused fields * separate out tagged html policy * s/eua/uuid * remove extra fields * only allow and

      tags for tagged html * Update postman, remove TaggedContent, remove console.info * return first err, update test file, fix some comments * add additional request for group tag test * naming --------- Co-authored-by: ClayBenson94 --- EASI.postman_collection.json | 37 +++- cmd/devdata/main.go | 50 ++++- .../system_intake_grb_review_discussions.go | 4 +- gqlgen.yml | 3 + pkg/graph/generated/generated.go | 30 ++- .../system_intake_grb_discussions.go | 4 +- pkg/graph/schema.graphql | 16 +- pkg/models/models_gen.go | 51 ++++- pkg/models/tag.go | 12 ++ pkg/models/tagged_html.go | 182 ++++++++++++++++++ pkg/models/tagged_html_test.go | 67 +++++++ pkg/sanitization/html.go | 15 +- pkg/sanitization/tagged_html.go | 36 ++++ src/gql/gen/graphql.ts | 12 +- 14 files changed, 482 insertions(+), 37 deletions(-) create mode 100644 pkg/models/tag.go create mode 100644 pkg/models/tagged_html.go create mode 100644 pkg/models/tagged_html_test.go create mode 100644 pkg/sanitization/tagged_html.go diff --git a/EASI.postman_collection.json b/EASI.postman_collection.json index 6ca77b1b79..6e057d4424 100644 --- a/EASI.postman_collection.json +++ b/EASI.postman_collection.json @@ -1178,8 +1178,41 @@ "body": { "mode": "graphql", "graphql": { - "query": "mutation createSystemIntakeGRBDiscussion($input: createSystemIntakeGRBDiscussionPostInput!) {\n createSystemIntakeGRBDiscussionPost(input: $input) {\n id\n content\n grbRole\n votingRole\n systemIntakeID\n createdByUserAccount {\n id\n username\n }\n }\n}", - "variables": "{\r\n \"input\": {\r\n \"systemIntakeID\": \"8edb237e-ad48-49b2-91cf-8534362bc6cf\",\r\n \"content\": \"

      banana apple carburetor

      \"\r\n }\r\n}" + "query": "mutation createSystemIntakeGRBDiscussion(\n $input: createSystemIntakeGRBDiscussionPostInput!\n) {\n createSystemIntakeGRBDiscussionPost(input: $input) {\n id\n content\n grbRole\n votingRole\n systemIntakeID\n createdByUserAccount {\n id\n username\n }\n }\n}\n", + "variables": "{\r\n \"input\": {\r\n \"systemIntakeID\": \"8edb237e-ad48-49b2-91cf-8534362bc6cf\",\r\n \"content\": \"

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!\\\" Visit W3Schools.com!

      \"\r\n }\r\n}" + } + }, + "url": { + "raw": "{{url}}", + "host": [ + "{{url}}" + ] + } + }, + "response": [] + }, + { + "name": "Create GRB Discussion Group Tags", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.collectionVariables.set(\"SystemIntakeGRBDiscussionID\", pm.response.json().data.createSystemIntakeGRBDiscussionPost.id)" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "mutation createSystemIntakeGRBDiscussion(\n $input: createSystemIntakeGRBDiscussionPostInput!\n) {\n createSystemIntakeGRBDiscussionPost(input: $input) {\n id\n content\n grbRole\n votingRole\n systemIntakeID\n createdByUserAccount {\n id\n username\n }\n }\n}\n", + "variables": "{\r\n \"input\": {\r\n \"systemIntakeID\": \"8edb237e-ad48-49b2-91cf-8534362bc6cf\",\r\n \"content\": \"

      banana apple carburetor Let me look into it, ok? @Group middle @Group2!\\\"

      \"\r\n }\r\n}" } }, "url": { diff --git a/cmd/devdata/main.go b/cmd/devdata/main.go index 348641e114..fd37b5f12c 100644 --- a/cmd/devdata/main.go +++ b/cmd/devdata/main.go @@ -352,24 +352,54 @@ func main() { // TODO, remove when they're not supported when we add tagging. Just using it now to show this is HTML // Initial Post - postA := createSystemIntakeGRBDiscussionPost(ctx, store, intake, models.HTML("Post A (Replies)")) + postA := createSystemIntakeGRBDiscussionPost(ctx, store, intake, models.TaggedHTML{ + RawContent: "Post A (Replies)", + Tags: nil, + }) // First reply is from an Admin -- default context user USR1 is an Admin - createSystemIntakeGRBDiscussionReply(userCtxITGovAdmin("ADMN"), store, postA.ID, models.HTML("Reply A1")) + createSystemIntakeGRBDiscussionReply(userCtxITGovAdmin("ADMN"), store, postA.ID, models.TaggedHTML{ + RawContent: "Reply A1", + Tags: nil, + }) // Then, create a reply from most of the GRB reviewers so we get replies from different non-admin GRB & Voting roles - createSystemIntakeGRBDiscussionReply(ctx, store, postA.ID, models.HTML("Reply A2")) - createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR2"), store, postA.ID, models.HTML("Reply A2")) - createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR3"), store, postA.ID, models.HTML("Reply A3")) - createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR4"), store, postA.ID, models.HTML("Reply A4")) + createSystemIntakeGRBDiscussionReply(ctx, store, postA.ID, models.TaggedHTML{ + RawContent: "Reply A2", + Tags: nil, + }) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR2"), store, postA.ID, models.TaggedHTML{ + RawContent: "Reply A2", + Tags: nil, + }) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR3"), store, postA.ID, models.TaggedHTML{ + RawContent: "Reply A3", + Tags: nil, + }) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR4"), store, postA.ID, models.TaggedHTML{ + RawContent: "Reply A4", + Tags: nil, + }) // Make one more thread with some replies - postB := createSystemIntakeGRBDiscussionPost(ctx, store, intake, models.HTML("Post B")) - createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR3"), store, postB.ID, models.HTML("Reply B1")) - createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR4"), store, postB.ID, models.HTML("Reply B2")) + postB := createSystemIntakeGRBDiscussionPost(ctx, store, intake, models.TaggedHTML{ + RawContent: "Post B", + Tags: nil, + }) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR3"), store, postB.ID, models.TaggedHTML{ + RawContent: "Reply B1", + Tags: nil, + }) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR4"), store, postB.ID, models.TaggedHTML{ + RawContent: "Reply B2", + Tags: nil, + }) // Lastly, create a new initial post with no replies - createSystemIntakeGRBDiscussionPost(ctx, store, intake, models.HTML("Post C (No replies)")) + createSystemIntakeGRBDiscussionPost(ctx, store, intake, models.TaggedHTML{ + RawContent: "Post C (No replies)", + Tags: nil, + }) intakeID = uuid.MustParse("d80cf287-35cb-4e76-b8b3-0467eabd75b8") makeSystemIntakeAndProgressToStep( diff --git a/cmd/devdata/system_intake_grb_review_discussions.go b/cmd/devdata/system_intake_grb_review_discussions.go index 3b5cf46b73..c1e3871e43 100644 --- a/cmd/devdata/system_intake_grb_review_discussions.go +++ b/cmd/devdata/system_intake_grb_review_discussions.go @@ -14,7 +14,7 @@ func createSystemIntakeGRBDiscussionPost( ctx context.Context, store *storage.Store, intake *models.SystemIntake, - content models.HTML, + content models.TaggedHTML, ) *models.SystemIntakeGRBReviewDiscussionPost { post, err := resolvers.CreateSystemIntakeGRBDiscussionPost( ctx, @@ -35,7 +35,7 @@ func createSystemIntakeGRBDiscussionReply( ctx context.Context, store *storage.Store, initialPostID uuid.UUID, - content models.HTML, + content models.TaggedHTML, ) *models.SystemIntakeGRBReviewDiscussionPost { reply, err := resolvers.CreateSystemIntakeGRBDiscussionReply( ctx, diff --git a/gqlgen.yml b/gqlgen.yml index 7bd01b1c67..86649adf59 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -84,6 +84,9 @@ models: HTML: model: - github.com/cms-enterprise/easi-app/pkg/models.HTML + TaggedHTML: + model: + - github.com/cms-enterprise/easi-app/pkg/models.TaggedHTML EmailAddress: model: - github.com/cms-enterprise/easi-app/pkg/models.EmailAddress diff --git a/pkg/graph/generated/generated.go b/pkg/graph/generated/generated.go index bb45b5f3e9..f4432b285b 100644 --- a/pkg/graph/generated/generated.go +++ b/pkg/graph/generated/generated.go @@ -9192,12 +9192,12 @@ type SystemIntakeGRBReviewDiscussion { input createSystemIntakeGRBDiscussionPostInput { systemIntakeID: UUID! - content: HTML! + content: TaggedHTML! } input createSystemIntakeGRBDiscussionReplyInput { initialPostID: UUID! - content: HTML! + content: TaggedHTML! } """ @@ -9668,6 +9668,14 @@ type TRBRequestAttendee { modifiedAt: Time } +# lint-disable defined-types-are-used +enum TagType { + USER_ACCOUNT + GROUP_IT_GOV + GROUP_GRB_REVIEWERS +} +# lint-enable defined-types-are-used + """ The data needed add a TRB request attendee to a TRB request """ @@ -10646,6 +10654,10 @@ HTML are represented using as strings,

      Notification emailNotification email]*>.*?`) + attributeRe = regexp.MustCompile(`(\S+)="([^"]+)"`) +) + +// TaggedHTML Is the input type for HTML that could contain tags +type TaggedHTML struct { + RawContent HTML + Tags []*Tag +} + +// UnmarshalGQLContext unmarshals the data from graphql to the TaggedHTML type +func (th *TaggedHTML) UnmarshalGQLContext(_ context.Context, v interface{}) error { + rawHTML, ok := v.(string) + if !ok { + return errors.New("invalid TaggedHTML") + } + + tc, err := NewTaggedHTMLFromString(rawHTML) + if err != nil { + return err + } + *th = TaggedHTML(tc) + + return nil +} + +// MarshalGQLContext marshals the TaggedHTML type to JSON to return to graphQL +func (th TaggedHTML) MarshalGQLContext(ctx context.Context, w io.Writer) error { + logger := appcontext.ZLogger(ctx) + + jsonValue, err := json.Marshal(th.RawContent) + if err != nil { + logger.Info("invalid TaggedHTML") + return fmt.Errorf("failed to marshal TaggedHTMLto JSON: %w", err) + } + + if _, err := w.Write(jsonValue); err != nil { + return fmt.Errorf("failed to write TaggedHTML to writer: %w", err) + } + + return nil +} + +func (th TaggedHTML) UniqueTags() []*Tag { + uniqueTags := lo.UniqBy(th.Tags, func(tag *Tag) string { + return fmt.Sprint(tag.TagType, tag.TaggedContentID.String()) + }) + + return uniqueTags +} + +// NewTaggedHTMLFromString converts an htmlString into TaggedHTML. It will store the input string as the raw content, +// and then sanitize and parse the input. +func NewTaggedHTMLFromString(htmlString string) (TaggedHTML, error) { + // sanitize + htmlString = sanitization.SanitizeTaggedHTML(htmlString) + tags, err := tagsFromStringRegex(htmlString) + if err != nil { + return TaggedHTML{}, err + } + + return TaggedHTML{ + RawContent: HTML(htmlString), + Tags: tags, + }, nil +} + +func tagsFromStringRegex(htmlString string) ([]*Tag, error) { + tagStrings := spanRe.FindAllString(htmlString, -1) + + var tags []*Tag + for _, tagString := range tagStrings { + parsedTag, err := parseTagRegEx(tagString) + if err != nil { + return nil, err + } + + tags = append(tags, &parsedTag) + } + + return tags, nil +} + +func parseTagRegEx(tag string) (Tag, error) { + attributes := extractAttributes(tag) + + tagType := TagType(attributes["tag-type"]) + if !tagType.IsValid() { + return Tag{}, fmt.Errorf("%s is not a valid tag type", tagType) + } + + class := attributes["class"] + if class != "mention" { + return Tag{}, fmt.Errorf("%s is not a valid class for tag", class) + } + + // if tag type is NOT a user account, there will be no UUID to parse + if tagType != TagTypeUserAccount { + return Tag{ + TagType: tagType, + }, nil + } + + id := attributes["data-id-db"] + if len(id) < 1 { + return Tag{}, errors.New("missing data-id-db in tag") + } + + parsed, err := uuid.Parse(id) + if err != nil { + return Tag{}, fmt.Errorf("failed to prase UUID when parsing tags: %w", err) + } + + return Tag{ + TagType: tagType, + TaggedContentID: parsed, + }, nil +} + +func extractAttributes(match string) map[string]string { + attributeMatches := attributeRe.FindAllStringSubmatch(match, -1) + + // Create a map to store the attribute key-value pairs + attributes := map[string]string{} + + // Iterate over the matches and extract the attribute name and value + for _, attributeMatch := range attributeMatches { + if len(attributeMatch) < 3 { + continue + } + + attributeName := attributeMatch[1] + attributeValue := attributeMatch[2] + + attributes[attributeName] = attributeValue + } + + return attributes +} + +func (th *TaggedHTML) Scan(src interface{}) error { + switch t := src.(type) { + case string: + tagHTML, err := NewTaggedHTMLFromString(t) + if err != nil { + return err + } + *th = tagHTML + + case []byte: + tagHTML, err := NewTaggedHTMLFromString(string(t)) + if err != nil { + return err + } + *th = tagHTML + } + + return nil +} + +func (th TaggedHTML) Value() (driver.Value, error) { + return string(th.RawContent), nil +} diff --git a/pkg/models/tagged_html_test.go b/pkg/models/tagged_html_test.go new file mode 100644 index 0000000000..14b3e8b496 --- /dev/null +++ b/pkg/models/tagged_html_test.go @@ -0,0 +1,67 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExtractHTMLSpansRegex(t *testing.T) { + htmlMention := `

      Hey @Alexander Stark! Will you be able to join the meeting next week? If not, can you contact @Terry Thompson to let them know?

      ` + mentionNodes, err := tagsFromStringRegex(htmlMention) + assert.NotNil(t, mentionNodes) + assert.NoError(t, err) + + assert.Len(t, mentionNodes, 2) + +} + +// TestExtractHTMLSpansRegexWithoutMentions ensures that we don't attempt to parse span elements +// that don't have a data-type="mention" attribute +func TestExtractHTMLSpansRegexWithoutMentions(t *testing.T) { + // Check case with NO mentions + html := `

      Hey @Alexander Stark! Will you be able to join the meeting next week? If not, can you contact @Terry Thompson to let them know?

      ` + mentionNodes, err := tagsFromStringRegex(html) + // Make sure we don't get an error, and that we get no results back + assert.NoError(t, err) + assert.Len(t, mentionNodes, 0) + + // Check case with 2 spans, but only one is a mention + html = `

      Hey @Alexander Stark! Will you be able to join the meeting next week? If not, can you contact @Terry Thompson to let them know?

      ` + mentionNodes, err = tagsFromStringRegex(html) + // Make sure we get an error. Any bad tags should fail the entire parse + assert.Error(t, err) + assert.Len(t, mentionNodes, 0) + + // Redacted Example from ECHIMP + html = `On December X, 20XX, we issued CR XXXXXX, Medicare Administrative Contractors (MACs) Updating Their Systems to Integrate with REDACTED, explaining the implementation process for the REDACTED (XXX) provider telephone survey. According to the CR’s Attachment B, Estimated Implementation Schedule, there is a 12 to 16 week process from design to go-live for each MAC. , is not a valid value for TagType error parsing html mention We plan to implement this CR using a phased approach, in this order:` + mentionNodes, err = tagsFromStringRegex(html) + // Make sure we get an error back as spans are only allowed when they are tags + assert.Error(t, err) + assert.Len(t, mentionNodes, 0) +} + +func TestHTMLMentionFromString(t *testing.T) { + tag1UUID := "4744bb54-c7d3-485a-9985-fdab4b86d28d" + tag1Name := "Alexander Stark" + tag1Type := TagTypeUserAccount + tag1 := `@` + tag1Name + `` + tag2UUID := "ab2b16ef-1928-4788-ad9c-263300ace094" + tag2Name := "Terry Thompson" + tag2Type := TagTypeUserAccount + tag2 := `@` + tag2Name + `` + tag3Name := "Salesforce" + tag3Type := TagTypeGroupGrbReviewers + tag3 := `@` + tag3Name + `` + htmlMention := `

      Hey ` + tag1 + `! Will you be able to join the meeting next week? If not, can you contact ` + tag2 + ` to let them know?

      We are planning on using the ` + tag3 + `solution.` + taggedHTML, err := NewTaggedHTMLFromString(htmlMention) + assert.NoError(t, err) + assert.Len(t, taggedHTML.Tags, 3) + + mention1 := taggedHTML.Tags[0] + mention2 := taggedHTML.Tags[1] + mention3 := taggedHTML.Tags[2] + assert.EqualValues(t, tag1Type, mention1.TagType) + assert.EqualValues(t, tag2Type, mention2.TagType) + assert.EqualValues(t, tag3Type, mention3.TagType) +} diff --git a/pkg/sanitization/html.go b/pkg/sanitization/html.go index 79e0bc6539..2e8afab3ee 100644 --- a/pkg/sanitization/html.go +++ b/pkg/sanitization/html.go @@ -6,9 +6,10 @@ import ( "github.com/microcosm-cc/bluemonday" ) -var once sync.Once - -var htmlSanitizerPolicy *bluemonday.Policy +var ( + htmlInitOnce sync.Once + htmlSanitizerPolicy *bluemonday.Policy +) // SanitizeHTML takes a string representation of HTML and sanitizes it func SanitizeHTML[stringType ~string](input stringType) stringType { @@ -19,13 +20,9 @@ func SanitizeHTML[stringType ~string](input stringType) stringType { return stringType(output) } -// getHTMLSanitizerPolicy returns the sanitization policy for HTML func getHTMLSanitizerPolicy() *bluemonday.Policy { - - // once ensures that a policy is instantiated once. Otherwise, it is just retrieved. - once.Do(func() { - policy := createHTMLPolicy() - htmlSanitizerPolicy = policy + htmlInitOnce.Do(func() { + htmlSanitizerPolicy = createHTMLPolicy() }) return htmlSanitizerPolicy diff --git a/pkg/sanitization/tagged_html.go b/pkg/sanitization/tagged_html.go new file mode 100644 index 0000000000..b9a884fd82 --- /dev/null +++ b/pkg/sanitization/tagged_html.go @@ -0,0 +1,36 @@ +package sanitization + +import ( + "sync" + + "github.com/microcosm-cc/bluemonday" +) + +var ( + taggedHTMLInitOnce sync.Once + taggedHTMLPolicy *bluemonday.Policy +) + +func SanitizeTaggedHTML[stringType ~string](input stringType) stringType { + policy := getTaggedHTMLPolicy() + + output := policy.Sanitize(string(input)) + + return stringType(output) +} + +func getTaggedHTMLPolicy() *bluemonday.Policy { + taggedHTMLInitOnce.Do(func() { + taggedHTMLPolicy = createTaggedHTMLPolicy() + }) + + return taggedHTMLPolicy +} + +func createTaggedHTMLPolicy() *bluemonday.Policy { + policy := bluemonday.NewPolicy() + // rules for tags + policy.AllowElements("span", "p") + policy.AllowAttrs("data-type", "class", "tag-type", "data-id-db").OnElements("span") + return policy +} diff --git a/src/gql/gen/graphql.ts b/src/gql/gen/graphql.ts index 4c56d4b99c..e1d80b1523 100644 --- a/src/gql/gen/graphql.ts +++ b/src/gql/gen/graphql.ts @@ -20,6 +20,8 @@ export type Scalars = { EmailAddress: { input: EmailAddress; output: EmailAddress; } /** HTML are represented using as strings,

      Notification email

      */ HTML: { input: HTML; output: HTML; } + /** TaggedHTML is represented using strings but can contain Tags (ex: @User) and possibly other richer elements than HTML */ + TaggedHTML: { input: any; output: any; } /** Time values are represented as strings using RFC3339 format, for example 2019-10-12T07:20:50.52Z */ Time: { input: Time; output: Time; } /** UUIDs are represented using 36 ASCII characters, for example B0511859-ADE6-4A67-8969-16EC280C0E1A */ @@ -3008,6 +3010,12 @@ export enum TRBWhereInProcessOption { THE_SYSTEM_IS_IN_OPERATION_AND_MAINTENANCE = 'THE_SYSTEM_IS_IN_OPERATION_AND_MAINTENANCE' } +export enum TagType { + GROUP_GRB_REVIEWERS = 'GROUP_GRB_REVIEWERS', + GROUP_IT_GOV = 'GROUP_IT_GOV', + USER_ACCOUNT = 'USER_ACCOUNT' +} + /** * Input data used to update the admin lead assigned to a system IT governance * request @@ -3219,12 +3227,12 @@ export type UserInfo = { }; export type CreateSystemIntakeGRBDiscussionPostInput = { - content: Scalars['HTML']['input']; + content: Scalars['TaggedHTML']['input']; systemIntakeID: Scalars['UUID']['input']; }; export type CreateSystemIntakeGRBDiscussionReplyInput = { - content: Scalars['HTML']['input']; + content: Scalars['TaggedHTML']['input']; initialPostID: Scalars['UUID']['input']; }; From 316650fe041069621c2ebe643d2486ddb8607c33 Mon Sep 17 00:00:00 2001 From: samoddball <156127704+samoddball@users.noreply.github.com> Date: Mon, 25 Nov 2024 10:12:42 -0700 Subject: [PATCH 10/22] disallow numbered /bullet lists (#2900) * disallow numbered /bullet lists * update extensions --- package.json | 5 +- src/components/MentionTextArea/index.tsx | 9 +- yarn.lock | 139 +++-------------------- 3 files changed, 26 insertions(+), 127 deletions(-) diff --git a/package.json b/package.json index 8ce85f0ac5..54c70c290e 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,13 @@ "@okta/okta-auth-js": "^7.8.0", "@okta/okta-react": "^6.9.0", "@okta/okta-signin-widget": "^7.23.0", + "@tiptap/core": "^2.10.2", + "@tiptap/extension-document": "^2.10.2", "@tiptap/extension-mention": "^2.9.1", + "@tiptap/extension-paragraph": "^2.10.2", + "@tiptap/extension-text": "^2.10.2", "@tiptap/pm": "^2.9.1", "@tiptap/react": "^2.9.1", - "@tiptap/starter-kit": "^2.9.1", "@tiptap/suggestion": "^2.9.1", "@toast-ui/react-editor": "^3.2.3", "@trussworks/react-uswds": "^3.2.0", diff --git a/src/components/MentionTextArea/index.tsx b/src/components/MentionTextArea/index.tsx index b4c54f0cb9..3842b99b77 100644 --- a/src/components/MentionTextArea/index.tsx +++ b/src/components/MentionTextArea/index.tsx @@ -1,14 +1,15 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -// import { useTranslation } from 'react-i18next'; +import Document from '@tiptap/extension-document'; import Mention from '@tiptap/extension-mention'; +import Paragraph from '@tiptap/extension-paragraph'; +import Text from '@tiptap/extension-text'; import { EditorContent, NodeViewWrapper, ReactNodeViewRenderer, useEditor } from '@tiptap/react'; -import StarterKit from '@tiptap/starter-kit'; import classNames from 'classnames'; import Alert from 'components/shared/Alert'; @@ -116,7 +117,9 @@ const MentionTextArea = React.forwardRef< } }, extensions: [ - StarterKit, + Document, + Paragraph, + Text, CustomMention.configure({ HTMLAttributes: { class: 'mention' diff --git a/yarn.lock b/yarn.lock index 5f9a7b0125..d0158e2d4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5729,20 +5729,10 @@ dependencies: "@babel/runtime" "^7.12.5" -"@tiptap/core@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.9.1.tgz#ceed211a9ecfe25a94e0e0863936169990e75aee" - integrity sha512-tifnLL/ARzQ6/FGEJjVwj9UT3v+pENdWHdk9x6F3X0mB1y0SeCjV21wpFLYESzwNdBPAj8NMp8Behv7dBnhIfw== - -"@tiptap/extension-blockquote@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-2.9.1.tgz#e27ae65b6eb753bf0bd4ed717121338a7358e299" - integrity sha512-Y0jZxc/pdkvcsftmEZFyG+73um8xrx6/DMfgUcNg3JAM63CISedNcr+OEI11L0oFk1KFT7/aQ9996GM6Kubdqg== - -"@tiptap/extension-bold@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-2.9.1.tgz#8f078766b043ab44208cb0610f1847263b4313cf" - integrity sha512-e2P1zGpnnt4+TyxTC5pX/lPxPasZcuHCYXY0iwQ3bf8qRQQEjDfj3X7EI+cXqILtnhOiviEOcYmeu5op2WhQDg== +"@tiptap/core@^2.10.2": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.10.2.tgz#2f5f2cee6d10dd65bac0353e0e30a309d96b0e10" + integrity sha512-jYLXbYHTi1stLla/74J8NJizDtcJ/uokhG+1gN4DMWHDujaZOrRZhW98o9gN5BYAp4zv//TVX8H+afLZwKGCKQ== "@tiptap/extension-bubble-menu@^2.9.1": version "2.9.1" @@ -5751,30 +5741,10 @@ dependencies: tippy.js "^6.3.7" -"@tiptap/extension-bullet-list@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.9.1.tgz#25d28f5f141404142be9f965413ab2ecea61de9e" - integrity sha512-0hizL/0j9PragJObjAWUVSuGhN1jKjCFnhLQVRxtx4HutcvS/lhoWMvFg6ZF8xqWgIa06n6A7MaknQkqhTdhKA== - -"@tiptap/extension-code-block@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-2.9.1.tgz#5a8c76729759e4505af40234c6011ad674ae4f7a" - integrity sha512-A/50wPWDqEUUUPhrwRKILP5gXMO5UlQ0F6uBRGYB9CEVOREam9yIgvONOnZVJtszHqOayjIVMXbH/JMBeq11/g== - -"@tiptap/extension-code@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.9.1.tgz#5652c379cbdf06f95c90f93085256b24d421d9d9" - integrity sha512-WQqcVGe7i/E+yO3wz5XQteU1ETNZ00euUEl4ylVVmH2NM4Dh0KDjEhbhHlCM0iCfLUo7jhjC7dmS+hMdPUb+Tg== - -"@tiptap/extension-document@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.9.1.tgz#ea65a86a4d2524ec65fc4775122f652840a89386" - integrity sha512-1a+HCoDPnBttjqExfYLwfABq8MYdiowhy/wp8eCxVb6KGFEENO53KapstISvPzqH7eOi+qRjBB1KtVYb/ZXicg== - -"@tiptap/extension-dropcursor@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-2.9.1.tgz#737a6b40272d5aaaedd068ec93433564ba330909" - integrity sha512-wJZspSmJRkDBtPkzFz1g7gvZOEOayk8s93UHsgbJxcV4VWHYleZ5XhT74sZunSjefNDm3qC6v2BSgLp3vNHVKQ== +"@tiptap/extension-document@^2.10.2": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.10.2.tgz#82c043cb29f5fd87b445c231fdc4f1b86e3c2177" + integrity sha512-Xodp6rMg6vtKZkyX3I6gVd6OZ9PNz9udhDLdCG6JscVJQPO8viV++39UOH416FCvRT46BdHWNCRu/xjUG1C0rA== "@tiptap/extension-floating-menu@^2.9.1": version "2.9.1" @@ -5783,70 +5753,20 @@ dependencies: tippy.js "^6.3.7" -"@tiptap/extension-gapcursor@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.9.1.tgz#04db79acd0d17f4aedfcf23233769ad2bf8a5817" - integrity sha512-jsRBmX01vr+5H02GljiHMo0n5H1vzoMLmFarxe0Yq2d2l9G/WV2VWX2XnGliqZAYWd1bI0phs7uLQIN3mxGQTw== - -"@tiptap/extension-hard-break@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.9.1.tgz#dac8d752801ca217305affb54507f2a1769acf80" - integrity sha512-fCuaOD/b7nDjm47PZ58oanq7y4ccS2wjPh42Qm0B0yipu/1fmC8eS1SmaXmk28F89BLtuL6uOCtR1spe+lZtlQ== - -"@tiptap/extension-heading@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.9.1.tgz#83a2cf3174b6e3da66298b5cd424aca8dc4738bb" - integrity sha512-SjZowzLixOFaCrV2cMaWi1mp8REK0zK1b3OcVx7bCZfVSmsOETJyrAIUpCKA8o60NwF7pwhBg0MN8oXlNKMeFw== - -"@tiptap/extension-history@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.9.1.tgz#7e60f4add5cdcbfa18a2edb7e9571c72f4c9c31a" - integrity sha512-wp9qR1NM+LpvyLZFmdNaAkDq0d4jDJ7z7Fz7icFQPu31NVxfQYO3IXNmvJDCNu8hFAbImpA5aG8MBuwzRo0H9w== - -"@tiptap/extension-horizontal-rule@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.9.1.tgz#90acbd931aadd52affa3f8ac2aecead70839b342" - integrity sha512-ydUhABeaBI1CoJp+/BBqPhXINfesp1qMNL/jiDcMsB66fsD4nOyphpAJT7FaRFZFtQVF06+nttBtFZVkITQVqg== - -"@tiptap/extension-italic@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.9.1.tgz#575f5f278d2f6999e0ad1e3b91010a010cb650e2" - integrity sha512-VkNA6Vz96+/+7uBlsgM7bDXXx4b62T1fDam/3UKifA72aD/fZckeWrbT7KrtdUbzuIniJSbA0lpTs5FY29+86Q== - -"@tiptap/extension-list-item@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.9.1.tgz#7e4e3f6805a716e683906901622eb9deb4be24f0" - integrity sha512-6O4NtYNR5N2Txi4AC0/4xMRJq9xd4+7ShxCZCDVL0WDVX37IhaqMO7LGQtA6MVlYyNaX4W1swfdJaqrJJ5HIUw== - "@tiptap/extension-mention@^2.9.1": version "2.9.1" resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-2.9.1.tgz#f35f68d40831395db83deae809db79d919cf948c" integrity sha512-2IzunpivdNtDNdtAXwRiQbNhTm87zrbkhz1cCE+2y9pWiX1QLXyx0HQq/DIAjxp6v7y4sIh+5UTUTFlH7vD9wQ== -"@tiptap/extension-ordered-list@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.9.1.tgz#fe9d560ac548ce2e16f51fc92dfcc12ac9f92231" - integrity sha512-6J9jtv1XP8dW7/JNSH/K4yiOABc92tBJtgCsgP8Ep4+fjfjdj4HbjS1oSPWpgItucF2Fp/VF8qg55HXhjxHjTw== +"@tiptap/extension-paragraph@^2.10.2": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.10.2.tgz#b4619f75a620c4685364754961c63f3950d4a562" + integrity sha512-EZG9W5rsU4uP585cIOrhbAPOUsgqrFbDrj1tZjTbvv0EWK03Un3FGYoGilkcUIxD9uB/XVHP+v2596Ifyi/dvQ== -"@tiptap/extension-paragraph@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.9.1.tgz#1cce648545b7b03d9af6fb393b0af602cf567135" - integrity sha512-JOmT0xd4gd3lIhLwrsjw8lV+ZFROKZdIxLi0Ia05XSu4RLrrvWj0zdKMSB+V87xOWfSB3Epo95zAvnPox5Q16A== - -"@tiptap/extension-strike@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.9.1.tgz#8c8553e81696e6c30a6801a1cae6afaa4c37f002" - integrity sha512-V5aEXdML+YojlPhastcu7w4biDPwmzy/fWq0T2qjfu5Te/THcqDmGYVBKESBm5x6nBy5OLkanw2O+KHu2quDdg== - -"@tiptap/extension-text-style@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-text-style/-/extension-text-style-2.9.1.tgz#b9fc9cd8e90747357fbd4cac541a33aaa8b76875" - integrity sha512-LAxc0SeeiPiAVBwksczeA7BJSZb6WtVpYhy5Esvy9K0mK5kttB4KxtnXWeQzMIJZQbza65yftGKfQlexf/Y7yg== - -"@tiptap/extension-text@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.9.1.tgz#e4cda144b0af916ee0dafb700f833cd40eeae6d9" - integrity sha512-3wo9uCrkLVLQFgbw2eFU37QAa1jq1/7oExa+FF/DVxdtHRS9E2rnUZ8s2hat/IWzvPUHXMwo3Zg2XfhoamQpCA== +"@tiptap/extension-text@^2.10.2": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.10.2.tgz#74efc2fc9ce9e7c1c572ea278a7095b3f140dd0a" + integrity sha512-7WaJCmHAnf24gZc+Bl64vZgjAFt0CSEc5Jr+f3GII6XeCkZpTCJX85po2MFUhBRZMJheyctyL+UfsRauo/iP0Q== "@tiptap/pm@^2.9.1": version "2.9.1" @@ -5883,33 +5803,6 @@ fast-deep-equal "^3" use-sync-external-store "^1.2.2" -"@tiptap/starter-kit@^2.9.1": - version "2.9.1" - resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.9.1.tgz#d990bfd8b8da5e13bc1c0eada7e00d6c77a09490" - integrity sha512-nsw6UF/7wDpPfHRhtGOwkj1ipIEiWZS1VGw+c14K61vM1CNj0uQ4jogbHwHZqN1dlL5Hh+FCqUHDPxG6ECbijg== - dependencies: - "@tiptap/core" "^2.9.1" - "@tiptap/extension-blockquote" "^2.9.1" - "@tiptap/extension-bold" "^2.9.1" - "@tiptap/extension-bullet-list" "^2.9.1" - "@tiptap/extension-code" "^2.9.1" - "@tiptap/extension-code-block" "^2.9.1" - "@tiptap/extension-document" "^2.9.1" - "@tiptap/extension-dropcursor" "^2.9.1" - "@tiptap/extension-gapcursor" "^2.9.1" - "@tiptap/extension-hard-break" "^2.9.1" - "@tiptap/extension-heading" "^2.9.1" - "@tiptap/extension-history" "^2.9.1" - "@tiptap/extension-horizontal-rule" "^2.9.1" - "@tiptap/extension-italic" "^2.9.1" - "@tiptap/extension-list-item" "^2.9.1" - "@tiptap/extension-ordered-list" "^2.9.1" - "@tiptap/extension-paragraph" "^2.9.1" - "@tiptap/extension-strike" "^2.9.1" - "@tiptap/extension-text" "^2.9.1" - "@tiptap/extension-text-style" "^2.9.1" - "@tiptap/pm" "^2.9.1" - "@tiptap/suggestion@^2.9.1": version "2.9.1" resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.9.1.tgz#e83cfadd47a7c45d75d26c8fbf7dac6d62022d79" From 7c588680a22a7b149017038d93ce835467645b31 Mon Sep 17 00:00:00 2001 From: ClayBenson94 Date: Tue, 26 Nov 2024 12:13:22 -0500 Subject: [PATCH 11/22] Fix GRB Discussions SQL files with new SQLFluff linter --- .../get_by_id_internal.sql | 20 +++---- .../get_by_intake_ids_internal.sql | 20 +++---- .../insert_internal.sql | 60 +++++++++---------- 3 files changed, 50 insertions(+), 50 deletions(-) diff --git a/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_id_internal.sql b/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_id_internal.sql index 31dde88173..c8367fe014 100644 --- a/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_id_internal.sql +++ b/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_id_internal.sql @@ -1,13 +1,13 @@ SELECT - id, - content, - voting_role, - grb_role, - system_intake_id, - reply_to_id, - modified_at, - modified_by, - created_at, - created_by + id, + content, + voting_role, + grb_role, + system_intake_id, + reply_to_id, + modified_at, + modified_by, + created_at, + created_by FROM system_intake_internal_grb_review_discussion_posts WHERE id = :id; diff --git a/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_intake_ids_internal.sql b/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_intake_ids_internal.sql index 1682a91c51..6ddf475ab5 100644 --- a/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_intake_ids_internal.sql +++ b/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_intake_ids_internal.sql @@ -1,13 +1,13 @@ SELECT - id, - content, - voting_role, - grb_role, - system_intake_id, - reply_to_id, - modified_at, - modified_by, - created_at, - created_by + id, + content, + voting_role, + grb_role, + system_intake_id, + reply_to_id, + modified_at, + modified_by, + created_at, + created_by FROM system_intake_internal_grb_review_discussion_posts WHERE system_intake_id = ANY(:system_intake_ids); diff --git a/pkg/sqlqueries/SQL/system_intake_grb_discussions/insert_internal.sql b/pkg/sqlqueries/SQL/system_intake_grb_discussions/insert_internal.sql index 9e85b2378d..c5c424e371 100644 --- a/pkg/sqlqueries/SQL/system_intake_grb_discussions/insert_internal.sql +++ b/pkg/sqlqueries/SQL/system_intake_grb_discussions/insert_internal.sql @@ -1,34 +1,34 @@ INSERT INTO system_intake_internal_grb_review_discussion_posts ( - id, - content, - voting_role, - grb_role, - system_intake_id, - reply_to_id, - created_by, - created_at, - modified_by, - modified_at + id, + content, + voting_role, + grb_role, + system_intake_id, + reply_to_id, + created_by, + created_at, + modified_by, + modified_at ) VALUES ( - :id, - :content, - :voting_role, - :grb_role, - :system_intake_id, - :reply_to_id, - :created_by, - :created_at, - :modified_by, - :modified_at + :id, + :content, + :voting_role, + :grb_role, + :system_intake_id, + :reply_to_id, + :created_by, + :created_at, + :modified_by, + :modified_at ) RETURNING -id, -content, -voting_role, -grb_role, -system_intake_id, -reply_to_id, -created_by, -created_at, -modified_by, -modified_at; + id, + content, + voting_role, + grb_role, + system_intake_id, + reply_to_id, + created_by, + created_at, + modified_by, + modified_at; From 4b99bca1dfd53dcb9f3c2c1de54683fa9c7feb3b Mon Sep 17 00:00:00 2001 From: Nick Downey <68014929+downeyn-cms@users.noreply.github.com> Date: Tue, 26 Nov 2024 14:43:59 -0500 Subject: [PATCH 12/22] [EASI-4614] Initial email work for GRB Discussions (#2875) * Add email templates and handlers for grb review discussions * Add tests for the new emails generated by grb review discussions * lint * naming * s/string/models.HTML * use builtin `template.HTML` for `DiscussionContent` to avoid escape * add fuller test data * update the other templates to have template.HTML * add the other two email types to `cmd/test_email_templates` --------- Co-authored-by: Clay Benson Co-authored-by: Lee Warrick <32332479+mynar7@users.noreply.github.com> Co-authored-by: samoddball <156127704+samoddball@users.noreply.github.com> Co-authored-by: samoddball --- cmd/test_email_templates/main.go | 51 ++++++++ pkg/email/email.go | 24 ++++ .../grb_review_discussion_group_tagged.go | 96 ++++++++++++++ ...grb_review_discussion_group_tagged_test.go | 123 ++++++++++++++++++ ...grb_review_discussion_individual_tagged.go | 90 +++++++++++++ ...eview_discussion_individual_tagged_test.go | 120 +++++++++++++++++ pkg/email/grb_review_discussion_reply.go | 88 +++++++++++++ pkg/email/grb_review_discussion_reply_test.go | 120 +++++++++++++++++ .../grb_review_discussion_group_tagged.gohtml | 23 ++++ ...review_discussion_individual_tagged.gohtml | 23 ++++ .../grb_review_discussion_reply.gohtml | 23 ++++ .../system_intake_grb_discussions.go | 8 +- .../resolvers/system_intake_grb_reviewer.go | 8 +- .../system_intake_grb_reviewer_test.go | 12 +- pkg/graph/schema.resolvers.go | 4 +- pkg/models/system_intake_grb_reviewers.go | 6 +- 16 files changed, 800 insertions(+), 19 deletions(-) create mode 100644 pkg/email/grb_review_discussion_group_tagged.go create mode 100644 pkg/email/grb_review_discussion_group_tagged_test.go create mode 100644 pkg/email/grb_review_discussion_individual_tagged.go create mode 100644 pkg/email/grb_review_discussion_individual_tagged_test.go create mode 100644 pkg/email/grb_review_discussion_reply.go create mode 100644 pkg/email/grb_review_discussion_reply_test.go create mode 100644 pkg/email/templates/grb_review_discussion_group_tagged.gohtml create mode 100644 pkg/email/templates/grb_review_discussion_individual_tagged.gohtml create mode 100644 pkg/email/templates/grb_review_discussion_reply.gohtml diff --git a/cmd/test_email_templates/main.go b/cmd/test_email_templates/main.go index a4ef0fd1ba..d26930c950 100644 --- a/cmd/test_email_templates/main.go +++ b/cmd/test_email_templates/main.go @@ -629,6 +629,57 @@ func sendITGovEmails(ctx context.Context, client *email.Client) { ) noErr(err) + err = client.SystemIntake.SendGRBReviewDiscussionReplyEmail( + ctx, + email.SendGRBReviewDiscussionReplyEmailInput{ + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + DiscussionBoardType: "Internal GRB Discussion Board", + GRBReviewLink: "google.com", + Role: "Voting Member", + DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, + DiscussionLink: "google.com", + ITGovernanceInboxAddress: "IT_Governance@cms.hhs.gov", + Recipients: emailNotificationRecipients.RegularRecipientEmails, + }, + ) + noErr(err) + + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail( + ctx, + email.SendGRBReviewDiscussionIndividualTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + DiscussionBoardType: "Internal GRB Discussion Board", + GRBReviewLink: "google.com", + Role: "Voting Member", + DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, + DiscussionLink: "google.com", + ITGovernanceInboxAddress: "IT_Governance@cms.hhs.gov", + Recipients: emailNotificationRecipients.RegularRecipientEmails, + }, + ) + noErr(err) + + err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail( + ctx, + email.SendGRBReviewDiscussionGroupTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + DiscussionBoardType: "Internal GRB Discussion Board", + GRBReviewLink: "google.com", + Role: "Voting Member", + DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, + DiscussionLink: "google.com", + ITGovernanceInboxAddress: "IT_Governance@cms.hhs.gov", + Recipients: emailNotificationRecipients.RegularRecipientEmails, + }, + ) + noErr(err) + err = client.SystemIntake.SendSystemIntakeAdminUploadDocEmail( ctx, email.SendSystemIntakeAdminUploadDocEmailInput{ diff --git a/pkg/email/email.go b/pkg/email/email.go index 8bef30574a..56ba75c6fb 100644 --- a/pkg/email/email.go +++ b/pkg/email/email.go @@ -76,6 +76,9 @@ type templates struct { systemIntakeUpdateLCID templateCaller systemIntakeChangeLCIDRetirementDate templateCaller systemIntakeCreateGRBReviewer templateCaller + grbReviewDiscussionReply templateCaller + grbReviewDiscussionIndividualTagged templateCaller + grbReviewDiscussionGroupTagged templateCaller } // sender is an interface for swapping out email provider implementations @@ -405,6 +408,27 @@ func NewClient(config Config, sender sender) (Client, error) { } appTemplates.systemIntakeCreateGRBReviewer = systemIntakeCreateGRBReviewer + grbReviewDiscussionReplyTemplateName := "grb_review_discussion_reply.gohtml" + grbReviewDiscussionReply := rawTemplates.Lookup(grbReviewDiscussionReplyTemplateName) + if grbReviewDiscussionReply == nil { + return Client{}, templateError(grbReviewDiscussionReplyTemplateName) + } + appTemplates.grbReviewDiscussionReply = grbReviewDiscussionReply + + grbReviewDiscussionIndividualTaggedTemplateName := "grb_review_discussion_individual_tagged.gohtml" + grbReviewDiscussionIndividualTagged := rawTemplates.Lookup(grbReviewDiscussionIndividualTaggedTemplateName) + if grbReviewDiscussionIndividualTagged == nil { + return Client{}, templateError(grbReviewDiscussionIndividualTaggedTemplateName) + } + appTemplates.grbReviewDiscussionIndividualTagged = grbReviewDiscussionIndividualTagged + + grbReviewDiscussionGroupTaggedTemplateName := "grb_review_discussion_group_tagged.gohtml" + grbReviewDiscussionGroupTagged := rawTemplates.Lookup(grbReviewDiscussionGroupTaggedTemplateName) + if grbReviewDiscussionGroupTagged == nil { + return Client{}, templateError(grbReviewDiscussionGroupTaggedTemplateName) + } + appTemplates.grbReviewDiscussionGroupTagged = grbReviewDiscussionGroupTagged + client := Client{ config: config, templates: appTemplates, diff --git a/pkg/email/grb_review_discussion_group_tagged.go b/pkg/email/grb_review_discussion_group_tagged.go new file mode 100644 index 0000000000..dde2e0bc1a --- /dev/null +++ b/pkg/email/grb_review_discussion_group_tagged.go @@ -0,0 +1,96 @@ +package email + +import ( + "bytes" + "context" + "errors" + "html/template" + "path" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/models" +) + +// SendGRBReviewDiscussionGroupTaggedEmailInput contains the data needed to to send an email informing an group they +// have been tagged in a GRB discussion +type SendGRBReviewDiscussionGroupTaggedEmailInput struct { + SystemIntakeID uuid.UUID + UserName string + GroupName string // TODO NJD enum? + RequestName string + DiscussionBoardType string + GRBReviewLink string + Role string + DiscussionContent template.HTML + DiscussionLink string + ITGovernanceInboxAddress string + Recipients []models.EmailAddress +} + +// GRBReviewDiscussionGroupTaggedBody contains the data needed for interpolation in +// the GRB Discussion Group Tagged email template +type GRBReviewDiscussionGroupTaggedBody struct { + SystemIntakeID uuid.UUID + UserName string + GroupName string + RequestName string + DiscussionBoardType string + GRBReviewLink string + Role string + DiscussionContent template.HTML + DiscussionLink string + ClientAddress string + ITGovernanceInboxAddress string +} + +func (sie systemIntakeEmails) grbReviewDiscussionGroupTaggedBody(input SendGRBReviewDiscussionGroupTaggedEmailInput) (string, error) { + grbReviewPath := path.Join("it-governance", input.SystemIntakeID.String(), "grb-review") + grbDiscussionPath := path.Join(grbReviewPath, "discussionID=BLAH") // TODO: NJD add actual discussion ID field + + data := GRBReviewDiscussionGroupTaggedBody{ + UserName: input.UserName, + GroupName: input.GroupName, + RequestName: input.RequestName, + DiscussionBoardType: input.DiscussionBoardType, + GRBReviewLink: sie.client.urlFromPath(grbReviewPath), + Role: input.Role, + DiscussionContent: input.DiscussionContent, + DiscussionLink: sie.client.urlFromPath(grbDiscussionPath), + ITGovernanceInboxAddress: input.ITGovernanceInboxAddress, + } + + if sie.client.templates.grbReviewDiscussionGroupTagged == nil { + return "", errors.New("grb review discussion reply template is nil") + } + + var b bytes.Buffer + if err := sie.client.templates.grbReviewDiscussionGroupTagged.Execute(&b, data); err != nil { + return "", err + } + + return b.String(), nil +} + +// SendGRBReviewDiscussionGroupTaggedEmail sends an email to an group indicating that they have +// been tagged in a GRB discussion +func (sie systemIntakeEmails) SendGRBReviewDiscussionGroupTaggedEmail(ctx context.Context, input SendGRBReviewDiscussionGroupTaggedEmailInput) error { + subject := "The " + input.GroupName + "was tagged in a GRB Review discussion for " + input.RequestName + + body, err := sie.grbReviewDiscussionGroupTaggedBody(input) + if err != nil { + return err + } + + // allRecipients := input.Recipients + allRecipients := []models.EmailAddress{} + allRecipients = append(allRecipients, models.NewEmailAddress("fake@fake.com")) + + return sie.client.sender.Send( + ctx, + NewEmail(). + WithToAddresses(allRecipients). // TODO: NJD cc and/or bcc? + WithSubject(subject). + WithBody(body), + ) +} diff --git a/pkg/email/grb_review_discussion_group_tagged_test.go b/pkg/email/grb_review_discussion_group_tagged_test.go new file mode 100644 index 0000000000..66ca248c75 --- /dev/null +++ b/pkg/email/grb_review_discussion_group_tagged_test.go @@ -0,0 +1,123 @@ +package email + +import ( + "context" + "fmt" + "html/template" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/models" +) + +func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotification() { + ctx := context.Background() + intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + userName := "Rock Lee" + groupName := "Governance Rreview Board" + requestName := "Salad/Sandwich Program" + discussionBoardType := "Internal GRB Discussion Board" + role := "Consumer" + discussionContent := template.HTML(`

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `) + + grbReviewLink := fmt.Sprintf( + "%s://%s/it-governance/%s/grb-review", + s.config.URLScheme, + s.config.URLHost, + intakeID.String(), + ) + + discussionLink := fmt.Sprintf( + "%s://%s/it-governance/%s/grb-review/discussionID=BLAH", + s.config.URLScheme, + s.config.URLHost, + intakeID.String(), + ) + ITGovInboxAddress := s.config.GRTEmail.String() + + sender := mockSender{} + recipient := models.NewEmailAddress("fake@fake.com") + recipients := []models.EmailAddress{recipient} + + input := SendGRBReviewDiscussionGroupTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + GroupName: groupName, + RequestName: requestName, + DiscussionBoardType: discussionBoardType, + GRBReviewLink: grbReviewLink, + Role: role, + DiscussionContent: discussionContent, + DiscussionLink: discussionLink, + ITGovernanceInboxAddress: ITGovInboxAddress, + Recipients: recipients, + } + + client, err := NewClient(s.config, &sender) + s.NoError(err) + + err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail(ctx, input) + s.NoError(err) + + getExpectedEmail := func() string { + return fmt.Sprintf(` +

      EASi

      +

      Easy Access to System Information

      + +

      %s tagged the %s in the %s for %s.

      + +

      View this request in EASi

      +
      + +

      Discussion

      +
      +

      %s

      +

      %s

      +

      %s

      +

      + + Reply in EASi + +

      + +
      +

      If you have questions, please contact the Governance Team at %s.

      +
      +
      +

      You will continue to receive email notifications about this request until it is closed.

      + `, + userName, + groupName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + role, + discussionContent, + discussionLink, + ITGovInboxAddress, + ITGovInboxAddress, + ) + } + + expectedEmail := getExpectedEmail() + expectedSubject := "The " + groupName + "was tagged in a GRB Review discussion for " + requestName + + s.Run("Subject is correct", func() { + s.Equal(expectedSubject, sender.subject) + + }) + + s.Run("Recipient is correct", func() { + allRecipients := []models.EmailAddress{ + recipient, + } + s.ElementsMatch(sender.toAddresses, allRecipients) + s.Empty(sender.ccAddresses) + s.Empty(sender.bccAddresses) + }) + + s.Run("all info is included", func() { + s.EqualHTML(expectedEmail, sender.body) + }) +} diff --git a/pkg/email/grb_review_discussion_individual_tagged.go b/pkg/email/grb_review_discussion_individual_tagged.go new file mode 100644 index 0000000000..2dd22d659e --- /dev/null +++ b/pkg/email/grb_review_discussion_individual_tagged.go @@ -0,0 +1,90 @@ +package email + +import ( + "bytes" + "context" + "errors" + "html/template" + "path" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/models" +) + +// SendGRBReviewDiscussionIndividualTaggedEmailInput contains the data needed to to send an email informing an individual they +// have been tagged in a GRB discussion +type SendGRBReviewDiscussionIndividualTaggedEmailInput struct { + SystemIntakeID uuid.UUID + UserName string + RequestName string + DiscussionBoardType string + GRBReviewLink string + Role string + DiscussionContent template.HTML + DiscussionLink string + ITGovernanceInboxAddress string + Recipients []models.EmailAddress +} + +// GRBReviewDiscussionIndividualTaggedBody contains the data needed for interpolation in +// the GRB Discussion Individual Tagged email template +type GRBReviewDiscussionIndividualTaggedBody struct { + SystemIntakeID uuid.UUID + UserName string + RequestName string + DiscussionBoardType string + GRBReviewLink string + Role string + DiscussionContent template.HTML + DiscussionLink string + ITGovernanceInboxAddress string +} + +func (sie systemIntakeEmails) grbReviewDiscussionIndividualTaggedBody(input SendGRBReviewDiscussionIndividualTaggedEmailInput) (string, error) { + grbReviewPath := path.Join("it-governance", input.SystemIntakeID.String(), "grb-review") + grbDiscussionPath := path.Join(grbReviewPath, "discussionID=BLAH") // TODO: NJD add actual discussion ID field + + data := GRBReviewDiscussionIndividualTaggedBody{ + UserName: input.UserName, + RequestName: input.RequestName, + DiscussionBoardType: input.DiscussionBoardType, + GRBReviewLink: sie.client.urlFromPath(grbReviewPath), + Role: input.Role, + DiscussionContent: input.DiscussionContent, + DiscussionLink: sie.client.urlFromPath(grbDiscussionPath), + ITGovernanceInboxAddress: input.ITGovernanceInboxAddress, + } + + if sie.client.templates.grbReviewDiscussionIndividualTagged == nil { + return "", errors.New("grb review discussion reply template is nil") + } + + var b bytes.Buffer + if err := sie.client.templates.grbReviewDiscussionIndividualTagged.Execute(&b, data); err != nil { + return "", err + } + + return b.String(), nil +} + +// SendGRBReviewDiscussionIndividualTaggedEmail sends an email to an individual indicating that they have +// been tagged in a GRB discussion +func (sie systemIntakeEmails) SendGRBReviewDiscussionIndividualTaggedEmail(ctx context.Context, input SendGRBReviewDiscussionIndividualTaggedEmailInput) error { + subject := "You were tagged in a GRB Review discussion for " + input.RequestName + + body, err := sie.grbReviewDiscussionIndividualTaggedBody(input) + if err != nil { + return err + } + + allRecipients := input.Recipients + + return sie.client.sender.Send( + ctx, + NewEmail(). + WithToAddresses(allRecipients). // TODO: NJD cc and/or bcc? + WithSubject(subject). + WithBody(body), + ) +} diff --git a/pkg/email/grb_review_discussion_individual_tagged_test.go b/pkg/email/grb_review_discussion_individual_tagged_test.go new file mode 100644 index 0000000000..d4fb7d5d4f --- /dev/null +++ b/pkg/email/grb_review_discussion_individual_tagged_test.go @@ -0,0 +1,120 @@ +package email + +import ( + "context" + "fmt" + "html/template" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/models" +) + +func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotification() { + ctx := context.Background() + intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + userName := "Rock Lee" + requestName := "Salad/Sandwich Program" + discussionBoardType := "Internal GRB Discussion Board" + role := "Consumer" + discussionContent := template.HTML(`

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `) + + grbReviewLink := fmt.Sprintf( + "%s://%s/it-governance/%s/grb-review", + s.config.URLScheme, + s.config.URLHost, + intakeID.String(), + ) + + discussionLink := fmt.Sprintf( + "%s://%s/it-governance/%s/grb-review/discussionID=BLAH", + s.config.URLScheme, + s.config.URLHost, + intakeID.String(), + ) + ITGovInboxAddress := s.config.GRTEmail.String() + + sender := mockSender{} + recipient := models.NewEmailAddress("fake@fake.com") + recipients := []models.EmailAddress{recipient} + + input := SendGRBReviewDiscussionIndividualTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + DiscussionBoardType: discussionBoardType, + GRBReviewLink: grbReviewLink, + Role: role, + DiscussionContent: discussionContent, + DiscussionLink: discussionLink, + ITGovernanceInboxAddress: ITGovInboxAddress, + Recipients: recipients, + } + + client, err := NewClient(s.config, &sender) + s.NoError(err) + + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail(ctx, input) + s.NoError(err) + + getExpectedEmail := func() string { + return fmt.Sprintf(` +

      EASi

      +

      Easy Access to System Information

      + +

      %s tagged you in the %s for %s.

      + +

      View this request in EASi

      +
      + +

      Discussion

      +
      +

      %s

      +

      %s

      +

      %s

      +

      + + Reply in EASi + +

      + +
      +

      If you have questions, please contact the Governance Team at %s.

      +
      +
      +

      You will continue to receive email notifications about this request until it is closed.

      + `, + userName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + role, + discussionContent, + discussionLink, + ITGovInboxAddress, + ITGovInboxAddress, + ) + } + + expectedEmail := getExpectedEmail() + expectedSubject := "You were tagged in a GRB Review discussion for " + requestName + + s.Run("Subject is correct", func() { + s.Equal(expectedSubject, sender.subject) + + }) + + s.Run("Recipient is correct", func() { + allRecipients := []models.EmailAddress{ + recipient, + } + s.ElementsMatch(sender.toAddresses, allRecipients) + s.Empty(sender.ccAddresses) + s.Empty(sender.bccAddresses) + }) + + s.Run("all info is included", func() { + s.EqualHTML(expectedEmail, sender.body) + }) +} diff --git a/pkg/email/grb_review_discussion_reply.go b/pkg/email/grb_review_discussion_reply.go new file mode 100644 index 0000000000..4168ef59bf --- /dev/null +++ b/pkg/email/grb_review_discussion_reply.go @@ -0,0 +1,88 @@ +package email + +import ( + "bytes" + "context" + "errors" + "html/template" + "path" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/models" +) + +// SendGRBReviewDiscussionReplyEmailInput contains the data needed to send the GRB discussion reply email +type SendGRBReviewDiscussionReplyEmailInput struct { + SystemIntakeID uuid.UUID + UserName string + RequestName string + DiscussionBoardType string + GRBReviewLink string + Role string + DiscussionContent template.HTML + DiscussionLink string + ITGovernanceInboxAddress string + Recipients []models.EmailAddress +} + +// GRBReviewDiscussionReplyBody contains the data needed for interpolation in +// the TRB advice letter submitted email template +type GRBReviewDiscussionReplyBody struct { + UserName string + RequestName string + DiscussionBoardType string + GRBReviewLink string + Role string + DiscussionContent template.HTML + DiscussionLink string + ITGovernanceInboxAddress string +} + +func (sie systemIntakeEmails) grbReviewDiscussionReplyBody(input SendGRBReviewDiscussionReplyEmailInput) (string, error) { + grbReviewPath := path.Join("it-governance", input.SystemIntakeID.String(), "grb-review") + grbDiscussionPath := path.Join(grbReviewPath, "discussionID=BLAH") // TODO: NJD add actual discussion ID field + + data := GRBReviewDiscussionReplyBody{ + UserName: input.UserName, + RequestName: input.RequestName, + DiscussionBoardType: input.DiscussionBoardType, + GRBReviewLink: sie.client.urlFromPath(grbReviewPath), + Role: input.Role, + DiscussionContent: input.DiscussionContent, + DiscussionLink: sie.client.urlFromPath(grbDiscussionPath), + ITGovernanceInboxAddress: input.ITGovernanceInboxAddress, + } + + if sie.client.templates.grbReviewDiscussionReply == nil { + return "", errors.New("grb review discussion reply template is nil") + } + + var b bytes.Buffer + if err := sie.client.templates.grbReviewDiscussionReply.Execute(&b, data); err != nil { + return "", err + } + + return b.String(), nil +} + +// SendGRBReviewDiscussionReplyEmail sends an email to the EASI admin team indicating that an advice letter +// has been submitted +func (sie systemIntakeEmails) SendGRBReviewDiscussionReplyEmail(ctx context.Context, input SendGRBReviewDiscussionReplyEmailInput) error { + subject := "New reply to your discussion in the GRB Review for " + input.RequestName + + body, err := sie.grbReviewDiscussionReplyBody(input) + if err != nil { + return err + } + + allRecipients := input.Recipients + + return sie.client.sender.Send( + ctx, + NewEmail(). + WithToAddresses(allRecipients). // TODO: NJD cc and/or bcc? + WithSubject(subject). + WithBody(body), + ) +} diff --git a/pkg/email/grb_review_discussion_reply_test.go b/pkg/email/grb_review_discussion_reply_test.go new file mode 100644 index 0000000000..83998a0084 --- /dev/null +++ b/pkg/email/grb_review_discussion_reply_test.go @@ -0,0 +1,120 @@ +package email + +import ( + "context" + "fmt" + "html/template" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/models" +) + +func (s *EmailTestSuite) TestCreateGRBReviewDiscussionReplyNotification() { + ctx := context.Background() + intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + userName := "Rock Lee" + requestName := "Salad/Sandwich Program" + discussionBoardType := "Internal GRB Discussion Board" + role := "Consumer" + discussionContent := template.HTML(`

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `) + + grbReviewLink := fmt.Sprintf( + "%s://%s/it-governance/%s/grb-review", + s.config.URLScheme, + s.config.URLHost, + intakeID.String(), + ) + + discussionLink := fmt.Sprintf( + "%s://%s/it-governance/%s/grb-review/discussionID=BLAH", + s.config.URLScheme, + s.config.URLHost, + intakeID.String(), + ) + ITGovInboxAddress := s.config.GRTEmail.String() + + sender := mockSender{} + recipient := models.NewEmailAddress("fake@fake.com") + recipients := []models.EmailAddress{recipient} + + input := SendGRBReviewDiscussionReplyEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + DiscussionBoardType: discussionBoardType, + GRBReviewLink: grbReviewLink, + Role: role, + DiscussionContent: discussionContent, + DiscussionLink: discussionLink, + ITGovernanceInboxAddress: ITGovInboxAddress, + Recipients: recipients, + } + + client, err := NewClient(s.config, &sender) + s.NoError(err) + + err = client.SystemIntake.SendGRBReviewDiscussionReplyEmail(ctx, input) + s.NoError(err) + + getExpectedEmail := func() string { + return fmt.Sprintf(` +

      EASi

      +

      Easy Access to System Information

      + +

      %s replied to your discussion on the %s for %s.

      + +

      View this request in EASi

      +
      + +

      Discussion

      +
      +

      %s

      +

      %s

      +

      %s

      +

      + + Reply in EASi + +

      + +
      +

      If you have questions, please contact the Governance Team at %s.

      +
      +
      +

      You will continue to receive email notifications about this request until it is closed.

      + `, + userName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + role, + discussionContent, + discussionLink, + ITGovInboxAddress, + ITGovInboxAddress, + ) + } + + expectedEmail := getExpectedEmail() + expectedSubject := "New reply to your discussion in the GRB Review for " + requestName + + s.Run("Subject is correct", func() { + s.Equal(expectedSubject, sender.subject) + + }) + + s.Run("Recipient is correct", func() { + allRecipients := []models.EmailAddress{ + recipient, + } + s.ElementsMatch(sender.toAddresses, allRecipients) + s.Empty(sender.ccAddresses) + s.Empty(sender.bccAddresses) + }) + + s.Run("all info is included", func() { + s.EqualHTML(expectedEmail, sender.body) + }) +} diff --git a/pkg/email/templates/grb_review_discussion_group_tagged.gohtml b/pkg/email/templates/grb_review_discussion_group_tagged.gohtml new file mode 100644 index 0000000000..c007ba9c58 --- /dev/null +++ b/pkg/email/templates/grb_review_discussion_group_tagged.gohtml @@ -0,0 +1,23 @@ +{{template "easi_header.gohtml"}} + +

      {{.UserName}} tagged the {{.GroupName}} in the {{.DiscussionBoardType}} for {{.RequestName}}.

      + +

      View this request in EASi

      +
      + +

      Discussion

      +
      +

      {{.UserName}}

      +

      {{.Role}}

      +

      {{.DiscussionContent}}

      +

      + + Reply in EASi + +

      + +
      +

      If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.

      +
      +
      +

      You will continue to receive email notifications about this request until it is closed.

      diff --git a/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml b/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml new file mode 100644 index 0000000000..c6bd562976 --- /dev/null +++ b/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml @@ -0,0 +1,23 @@ +{{template "easi_header.gohtml"}} + +

      {{.UserName}} tagged you in the {{.DiscussionBoardType}} for {{.RequestName}}.

      + +

      View this request in EASi

      +
      + +

      Discussion

      +
      +

      {{.UserName}}

      +

      {{.Role}}

      +

      {{.DiscussionContent}}

      +

      + + Reply in EASi + +

      + +
      +

      If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.

      +
      +
      +

      You will continue to receive email notifications about this request until it is closed.

      diff --git a/pkg/email/templates/grb_review_discussion_reply.gohtml b/pkg/email/templates/grb_review_discussion_reply.gohtml new file mode 100644 index 0000000000..34367b2195 --- /dev/null +++ b/pkg/email/templates/grb_review_discussion_reply.gohtml @@ -0,0 +1,23 @@ +{{template "easi_header.gohtml"}} + +

      {{.UserName}} replied to your discussion on the {{.DiscussionBoardType}} for {{.RequestName}}.

      + +

      View this request in EASi

      +
      + +

      Discussion

      +
      +

      {{.UserName}}

      +

      {{.Role}}

      +

      {{.DiscussionContent}}

      +

      + + Reply in EASi + +

      + +
      +

      If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.

      +
      +
      +

      You will continue to receive email notifications about this request until it is closed.

      \ No newline at end of file diff --git a/pkg/graph/resolvers/system_intake_grb_discussions.go b/pkg/graph/resolvers/system_intake_grb_discussions.go index a9e218c5c2..a9aa4f1444 100644 --- a/pkg/graph/resolvers/system_intake_grb_discussions.go +++ b/pkg/graph/resolvers/system_intake_grb_discussions.go @@ -36,8 +36,8 @@ func CreateSystemIntakeGRBDiscussionPost( post.Content = input.Content.RawContent post.SystemIntakeID = intakeID if principalGRBReviewer != nil { - post.VotingRole = &principalGRBReviewer.VotingRole - post.GRBRole = &principalGRBReviewer.GRBRole + post.VotingRole = &principalGRBReviewer.GRBVotingRole + post.GRBRole = &principalGRBReviewer.GRBReviewerRole } return store.CreateSystemIntakeGRBDiscussionPost(ctx, tx, post) }) @@ -72,8 +72,8 @@ func CreateSystemIntakeGRBDiscussionReply( post.SystemIntakeID = intakeID post.ReplyToID = &initialPost.ID if principalGRBReviewer != nil { - post.VotingRole = &principalGRBReviewer.VotingRole - post.GRBRole = &principalGRBReviewer.GRBRole + post.VotingRole = &principalGRBReviewer.GRBVotingRole + post.GRBRole = &principalGRBReviewer.GRBReviewerRole } return store.CreateSystemIntakeGRBDiscussionPost(ctx, tx, post) }) diff --git a/pkg/graph/resolvers/system_intake_grb_reviewer.go b/pkg/graph/resolvers/system_intake_grb_reviewer.go index 1c9adc740c..c2bbf6acda 100644 --- a/pkg/graph/resolvers/system_intake_grb_reviewer.go +++ b/pkg/graph/resolvers/system_intake_grb_reviewer.go @@ -55,8 +55,8 @@ func CreateSystemIntakeGRBReviewers( for _, acct := range accts { reviewerInput := reviewersByEUAMap[acct.Username] reviewer := models.NewSystemIntakeGRBReviewer(acct.ID, createdByID) - reviewer.VotingRole = models.SIGRBReviewerVotingRole(reviewerInput.VotingRole) - reviewer.GRBRole = models.SIGRBReviewerRole(reviewerInput.GrbRole) + reviewer.GRBVotingRole = models.SIGRBReviewerVotingRole(reviewerInput.VotingRole) + reviewer.GRBReviewerRole = models.SIGRBReviewerRole(reviewerInput.GrbRole) reviewer.SystemIntakeID = input.SystemIntakeID reviewersToCreate = append(reviewersToCreate, reviewer) } @@ -144,8 +144,8 @@ func SystemIntakeCompareGRBReviewers( HasLoggedIn: comparison.HasLoggedIn, }, EuaUserID: comparison.EuaID, - VotingRole: models.SystemIntakeGRBReviewerVotingRole(comparison.VotingRole), - GrbRole: models.SystemIntakeGRBReviewerRole(comparison.GRBRole), + VotingRole: models.SystemIntakeGRBReviewerVotingRole(comparison.GRBVotingRole), + GrbRole: models.SystemIntakeGRBReviewerRole(comparison.GRBReviewerRole), IsCurrentReviewer: comparison.IsCurrentReviewer, } // Add the reviewer to the slice if an entry exists diff --git a/pkg/graph/resolvers/system_intake_grb_reviewer_test.go b/pkg/graph/resolvers/system_intake_grb_reviewer_test.go index a1aaabce6d..d44d3d2c67 100644 --- a/pkg/graph/resolvers/system_intake_grb_reviewer_test.go +++ b/pkg/graph/resolvers/system_intake_grb_reviewer_test.go @@ -54,12 +54,12 @@ func (s *ResolverSuite) TestSystemIntakeGRBReviewer() { reviewers := payload.Reviewers s.NoError(err) s.Equal(userAccts[0].ID, reviewers[0].UserID) - s.Equal(string(votingRole1), string(reviewers[0].VotingRole)) - s.Equal(string(grbRole1), string(reviewers[0].GRBRole)) + s.Equal(string(votingRole1), string(reviewers[0].GRBVotingRole)) + s.Equal(string(grbRole1), string(reviewers[0].GRBReviewerRole)) s.Equal(intake.ID, reviewers[0].SystemIntakeID) s.Equal(userAccts[1].ID, reviewers[1].UserID) - s.Equal(string(votingRole2), string(reviewers[1].VotingRole)) - s.Equal(string(grbRole2), string(reviewers[1].GRBRole)) + s.Equal(string(votingRole2), string(reviewers[1].GRBVotingRole)) + s.Equal(string(grbRole2), string(reviewers[1].GRBReviewerRole)) s.Equal(intake.ID, reviewers[1].SystemIntakeID) s.False(sender.emailWasSent) }) @@ -123,8 +123,8 @@ func (s *ResolverSuite) TestSystemIntakeGRBReviewer() { s.Equal(reviewer.ID, updatedReviewer.ID) s.Equal(reviewer.UserID, updatedReviewer.UserID) s.Equal(intake.ID, updatedReviewer.SystemIntakeID) - s.Equal(newVotingRole, updatedReviewer.VotingRole) - s.Equal(newGRBRole, updatedReviewer.GRBRole) + s.Equal(newVotingRole, updatedReviewer.GRBVotingRole) + s.Equal(newGRBRole, updatedReviewer.GRBReviewerRole) }) s.Run("delete GRB reviewer", func() { diff --git a/pkg/graph/schema.resolvers.go b/pkg/graph/schema.resolvers.go index 6dc42b4a17..6e25d0966c 100644 --- a/pkg/graph/schema.resolvers.go +++ b/pkg/graph/schema.resolvers.go @@ -1988,12 +1988,12 @@ func (r *systemIntakeGRBReviewDiscussionPostResolver) GrbRole(ctx context.Contex // VotingRole is the resolver for the votingRole field. func (r *systemIntakeGRBReviewerResolver) VotingRole(ctx context.Context, obj *models.SystemIntakeGRBReviewer) (models.SystemIntakeGRBReviewerVotingRole, error) { - return models.SystemIntakeGRBReviewerVotingRole(obj.VotingRole), nil + return models.SystemIntakeGRBReviewerVotingRole(obj.GRBVotingRole), nil } // GrbRole is the resolver for the grbRole field. func (r *systemIntakeGRBReviewerResolver) GrbRole(ctx context.Context, obj *models.SystemIntakeGRBReviewer) (models.SystemIntakeGRBReviewerRole, error) { - return models.SystemIntakeGRBReviewerRole(obj.GRBRole), nil + return models.SystemIntakeGRBReviewerRole(obj.GRBReviewerRole), nil } // Author is the resolver for the author field. diff --git a/pkg/models/system_intake_grb_reviewers.go b/pkg/models/system_intake_grb_reviewers.go index 04aa88a167..5c5cd01f8f 100644 --- a/pkg/models/system_intake_grb_reviewers.go +++ b/pkg/models/system_intake_grb_reviewers.go @@ -37,9 +37,9 @@ const ( type SystemIntakeGRBReviewer struct { BaseStructUser userIDRelation - SystemIntakeID uuid.UUID `json:"systemIntakeId" db:"system_intake_id"` - VotingRole SIGRBReviewerVotingRole `json:"votingRole" db:"voting_role"` - GRBRole SIGRBReviewerRole `json:"grbRole" db:"grb_role"` + SystemIntakeID uuid.UUID `json:"systemIntakeId" db:"system_intake_id"` + GRBVotingRole SIGRBReviewerVotingRole `json:"votingRole" db:"voting_role"` + GRBReviewerRole SIGRBReviewerRole `json:"grbRole" db:"grb_role"` } func NewSystemIntakeGRBReviewer(userID uuid.UUID, createdBy uuid.UUID) *SystemIntakeGRBReviewer { From b1d16ace4c27e0dfe81267f86799d0d4aa4023f2 Mon Sep 17 00:00:00 2001 From: samoddball <156127704+samoddball@users.noreply.github.com> Date: Mon, 2 Dec 2024 13:38:42 -0700 Subject: [PATCH 13/22] Easi 4614/follow up items (#2906) * adding `IsAdmin` field for conditional template rendering * add conditionals to html * add mention * test mention color, fix horizontal divider * update tests * add admin version to test and devdata * fix test * add doc, update to only one recipient where only one is needed * use BCC * fix tests --- cmd/test_email_templates/main.go | 58 ++++++++- pkg/email/email.go | 9 ++ .../grb_review_discussion_group_tagged.go | 24 ++-- ...grb_review_discussion_group_tagged_test.go | 114 +++++++++++++++++- ...grb_review_discussion_individual_tagged.go | 22 ++-- ...eview_discussion_individual_tagged_test.go | 107 +++++++++++++++- pkg/email/grb_review_discussion_reply.go | 22 ++-- pkg/email/grb_review_discussion_reply_test.go | 110 ++++++++++++++++- pkg/email/templates/easi_header.gohtml | 4 + .../grb_review_discussion_group_tagged.gohtml | 11 +- ...review_discussion_individual_tagged.gohtml | 17 +-- .../grb_review_discussion_reply.gohtml | 19 +-- 12 files changed, 456 insertions(+), 61 deletions(-) diff --git a/cmd/test_email_templates/main.go b/cmd/test_email_templates/main.go index d26930c950..9df1f7ed33 100644 --- a/cmd/test_email_templates/main.go +++ b/cmd/test_email_templates/main.go @@ -641,7 +641,7 @@ func sendITGovEmails(ctx context.Context, client *email.Client) { DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, DiscussionLink: "google.com", ITGovernanceInboxAddress: "IT_Governance@cms.hhs.gov", - Recipients: emailNotificationRecipients.RegularRecipientEmails, + Recipient: requesterEmail, }, ) noErr(err) @@ -658,7 +658,43 @@ func sendITGovEmails(ctx context.Context, client *email.Client) { DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, DiscussionLink: "google.com", ITGovernanceInboxAddress: "IT_Governance@cms.hhs.gov", - Recipients: emailNotificationRecipients.RegularRecipientEmails, + Recipient: requesterEmail, + }, + ) + noErr(err) + + // admin version + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail( + ctx, + email.SendGRBReviewDiscussionIndividualTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + DiscussionBoardType: "Internal GRB Discussion Board", + GRBReviewLink: "google.com", + Role: "", // empty to signify admin + DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, + DiscussionLink: "google.com", + ITGovernanceInboxAddress: "IT_Governance@cms.hhs.gov", + Recipient: requesterEmail, + }, + ) + noErr(err) + + // admin version + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail( + ctx, + email.SendGRBReviewDiscussionIndividualTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + DiscussionBoardType: "Internal GRB Discussion Board", + GRBReviewLink: "google.com", + Role: "", // empty to signify admin + DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, + DiscussionLink: "google.com", + ITGovernanceInboxAddress: "IT_Governance@cms.hhs.gov", + Recipient: requesterEmail, }, ) noErr(err) @@ -680,6 +716,24 @@ func sendITGovEmails(ctx context.Context, client *email.Client) { ) noErr(err) + // admin version + err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail( + ctx, + email.SendGRBReviewDiscussionGroupTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + DiscussionBoardType: "Internal GRB Discussion Board", + GRBReviewLink: "google.com", + Role: "", // empty to signify admin + DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, + DiscussionLink: "google.com", + ITGovernanceInboxAddress: "IT_Governance@cms.hhs.gov", + Recipients: emailNotificationRecipients.RegularRecipientEmails, + }, + ) + noErr(err) + err = client.SystemIntake.SendSystemIntakeAdminUploadDocEmail( ctx, email.SendSystemIntakeAdminUploadDocEmailInput{ diff --git a/pkg/email/email.go b/pkg/email/email.go index 56ba75c6fb..0295711c95 100644 --- a/pkg/email/email.go +++ b/pkg/email/email.go @@ -493,6 +493,9 @@ func HumanizeSnakeCase(s string) string { return strings.Join(wordSlice, " ") } +// Email consists of the basic components of an email +// note: only one of `To`/`CC`/`BCC` needed to send an email +// ex: you can send to only BCC recipients if desired type Email struct { ToAddresses []models.EmailAddress CcAddresses []models.EmailAddress @@ -501,30 +504,36 @@ type Email struct { Body string } +// NewEmail returns an empty email object func NewEmail() Email { return Email{} } +// WithToAddresses sets the `To` field on an email func (e Email) WithToAddresses(toAddresses []models.EmailAddress) Email { e.ToAddresses = toAddresses return e } +// WithCCAddresses sets the `CC` field on an email func (e Email) WithCCAddresses(ccAddresses []models.EmailAddress) Email { e.CcAddresses = ccAddresses return e } +// WithBCCAddresses sets the `BCC` field on an email func (e Email) WithBCCAddresses(bccAddresses []models.EmailAddress) Email { e.BccAddresses = bccAddresses return e } +// WithSubject sets the Subject on an email func (e Email) WithSubject(subject string) Email { e.Subject = subject return e } +// WithBody sets the content (body) of an email func (e Email) WithBody(body string) Email { e.Body = body return e diff --git a/pkg/email/grb_review_discussion_group_tagged.go b/pkg/email/grb_review_discussion_group_tagged.go index dde2e0bc1a..168d8f3338 100644 --- a/pkg/email/grb_review_discussion_group_tagged.go +++ b/pkg/email/grb_review_discussion_group_tagged.go @@ -12,7 +12,7 @@ import ( "github.com/cms-enterprise/easi-app/pkg/models" ) -// SendGRBReviewDiscussionGroupTaggedEmailInput contains the data needed to to send an email informing an group they +// SendGRBReviewDiscussionGroupTaggedEmailInput contains the data needed to send an email informing a group they // have been tagged in a GRB discussion type SendGRBReviewDiscussionGroupTaggedEmailInput struct { SystemIntakeID uuid.UUID @@ -42,11 +42,20 @@ type GRBReviewDiscussionGroupTaggedBody struct { DiscussionLink string ClientAddress string ITGovernanceInboxAddress string + IsAdmin bool } func (sie systemIntakeEmails) grbReviewDiscussionGroupTaggedBody(input SendGRBReviewDiscussionGroupTaggedEmailInput) (string, error) { + if sie.client.templates.grbReviewDiscussionGroupTagged == nil { + return "", errors.New("grb review discussion reply template is nil") + } + grbReviewPath := path.Join("it-governance", input.SystemIntakeID.String(), "grb-review") grbDiscussionPath := path.Join(grbReviewPath, "discussionID=BLAH") // TODO: NJD add actual discussion ID field + role := input.Role + if len(role) < 1 { + role = "Governance Admin Team" + } data := GRBReviewDiscussionGroupTaggedBody{ UserName: input.UserName, @@ -54,14 +63,11 @@ func (sie systemIntakeEmails) grbReviewDiscussionGroupTaggedBody(input SendGRBRe RequestName: input.RequestName, DiscussionBoardType: input.DiscussionBoardType, GRBReviewLink: sie.client.urlFromPath(grbReviewPath), - Role: input.Role, + Role: role, DiscussionContent: input.DiscussionContent, DiscussionLink: sie.client.urlFromPath(grbDiscussionPath), ITGovernanceInboxAddress: input.ITGovernanceInboxAddress, - } - - if sie.client.templates.grbReviewDiscussionGroupTagged == nil { - return "", errors.New("grb review discussion reply template is nil") + IsAdmin: len(input.Role) < 1, } var b bytes.Buffer @@ -72,7 +78,7 @@ func (sie systemIntakeEmails) grbReviewDiscussionGroupTaggedBody(input SendGRBRe return b.String(), nil } -// SendGRBReviewDiscussionGroupTaggedEmail sends an email to an group indicating that they have +// SendGRBReviewDiscussionGroupTaggedEmail sends an email to a group indicating that they have // been tagged in a GRB discussion func (sie systemIntakeEmails) SendGRBReviewDiscussionGroupTaggedEmail(ctx context.Context, input SendGRBReviewDiscussionGroupTaggedEmailInput) error { subject := "The " + input.GroupName + "was tagged in a GRB Review discussion for " + input.RequestName @@ -82,14 +88,14 @@ func (sie systemIntakeEmails) SendGRBReviewDiscussionGroupTaggedEmail(ctx contex return err } - // allRecipients := input.Recipients allRecipients := []models.EmailAddress{} allRecipients = append(allRecipients, models.NewEmailAddress("fake@fake.com")) return sie.client.sender.Send( ctx, NewEmail(). - WithToAddresses(allRecipients). // TODO: NJD cc and/or bcc? + // use BCC as this is going to multiple recipients + WithBCCAddresses(allRecipients). WithSubject(subject). WithBody(body), ) diff --git a/pkg/email/grb_review_discussion_group_tagged_test.go b/pkg/email/grb_review_discussion_group_tagged_test.go index 66ca248c75..5d091bab73 100644 --- a/pkg/email/grb_review_discussion_group_tagged_test.go +++ b/pkg/email/grb_review_discussion_group_tagged_test.go @@ -14,7 +14,7 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotification() ctx := context.Background() intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") userName := "Rock Lee" - groupName := "Governance Rreview Board" + groupName := "Governance Review Board" requestName := "Salad/Sandwich Program" discussionBoardType := "Internal GRB Discussion Board" role := "Consumer" @@ -70,7 +70,7 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotification()

      Discussion

      -
      +

      %s

      %s

      %s

      @@ -80,10 +80,10 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotification()

      +

      If you have questions, please contact the Governance Team at %s.


      -

      You will continue to receive email notifications about this request until it is closed.

      `, userName, @@ -108,13 +108,117 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotification() }) + s.Run("Recipient is correct", func() { + s.ElementsMatch(sender.bccAddresses, recipients) + s.Empty(sender.ccAddresses) + s.Empty(sender.toAddresses) + }) + + s.Run("all info is included", func() { + s.EqualHTML(expectedEmail, sender.body) + }) +} + +func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotificationAdmin() { + ctx := context.Background() + intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + userName := "Rock Lee" + groupName := "Governance Review Board" + requestName := "Salad/Sandwich Program" + discussionBoardType := "Internal GRB Discussion Board" + role := "" // empty to signify admin + discussionContent := template.HTML(`

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `) + + grbReviewLink := fmt.Sprintf( + "%s://%s/it-governance/%s/grb-review", + s.config.URLScheme, + s.config.URLHost, + intakeID.String(), + ) + + discussionLink := fmt.Sprintf( + "%s://%s/it-governance/%s/grb-review/discussionID=BLAH", + s.config.URLScheme, + s.config.URLHost, + intakeID.String(), + ) + ITGovInboxAddress := s.config.GRTEmail.String() + + sender := mockSender{} + recipient := models.NewEmailAddress("fake@fake.com") + recipients := []models.EmailAddress{recipient} + + input := SendGRBReviewDiscussionGroupTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + GroupName: groupName, + RequestName: requestName, + DiscussionBoardType: discussionBoardType, + GRBReviewLink: grbReviewLink, + Role: role, + DiscussionContent: discussionContent, + DiscussionLink: discussionLink, + ITGovernanceInboxAddress: ITGovInboxAddress, + Recipients: recipients, + } + + client, err := NewClient(s.config, &sender) + s.NoError(err) + + err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail(ctx, input) + s.NoError(err) + + getExpectedEmail := func() string { + return fmt.Sprintf(` +

      EASi

      +

      Easy Access to System Information

      + +

      %s tagged the %s in the %s for %s.

      + +

      View this request in EASi

      +
      + +

      Discussion

      +
      +

      %s

      +

      Governance Admin Team

      +

      %s

      +

      + + Reply in EASi + +

      + +
      +
      +

      You will continue to receive email notifications about this request until it is closed.

      + `, + userName, + groupName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + discussionContent, + discussionLink, + ) + } + + expectedEmail := getExpectedEmail() + expectedSubject := "The " + groupName + "was tagged in a GRB Review discussion for " + requestName + + s.Run("Subject is correct", func() { + s.Equal(expectedSubject, sender.subject) + + }) + s.Run("Recipient is correct", func() { allRecipients := []models.EmailAddress{ recipient, } - s.ElementsMatch(sender.toAddresses, allRecipients) + s.ElementsMatch(sender.bccAddresses, allRecipients) + s.Empty(sender.toAddresses) s.Empty(sender.ccAddresses) - s.Empty(sender.bccAddresses) }) s.Run("all info is included", func() { diff --git a/pkg/email/grb_review_discussion_individual_tagged.go b/pkg/email/grb_review_discussion_individual_tagged.go index 2dd22d659e..52f260879d 100644 --- a/pkg/email/grb_review_discussion_individual_tagged.go +++ b/pkg/email/grb_review_discussion_individual_tagged.go @@ -24,7 +24,7 @@ type SendGRBReviewDiscussionIndividualTaggedEmailInput struct { DiscussionContent template.HTML DiscussionLink string ITGovernanceInboxAddress string - Recipients []models.EmailAddress + Recipient models.EmailAddress } // GRBReviewDiscussionIndividualTaggedBody contains the data needed for interpolation in @@ -39,25 +39,31 @@ type GRBReviewDiscussionIndividualTaggedBody struct { DiscussionContent template.HTML DiscussionLink string ITGovernanceInboxAddress string + IsAdmin bool } func (sie systemIntakeEmails) grbReviewDiscussionIndividualTaggedBody(input SendGRBReviewDiscussionIndividualTaggedEmailInput) (string, error) { + if sie.client.templates.grbReviewDiscussionIndividualTagged == nil { + return "", errors.New("grb review discussion reply template is nil") + } + grbReviewPath := path.Join("it-governance", input.SystemIntakeID.String(), "grb-review") grbDiscussionPath := path.Join(grbReviewPath, "discussionID=BLAH") // TODO: NJD add actual discussion ID field + role := input.Role + if len(role) < 1 { + role = "Governance Admin Team" + } data := GRBReviewDiscussionIndividualTaggedBody{ UserName: input.UserName, RequestName: input.RequestName, DiscussionBoardType: input.DiscussionBoardType, GRBReviewLink: sie.client.urlFromPath(grbReviewPath), - Role: input.Role, + Role: role, DiscussionContent: input.DiscussionContent, DiscussionLink: sie.client.urlFromPath(grbDiscussionPath), ITGovernanceInboxAddress: input.ITGovernanceInboxAddress, - } - - if sie.client.templates.grbReviewDiscussionIndividualTagged == nil { - return "", errors.New("grb review discussion reply template is nil") + IsAdmin: len(input.Role) < 1, } var b bytes.Buffer @@ -78,12 +84,10 @@ func (sie systemIntakeEmails) SendGRBReviewDiscussionIndividualTaggedEmail(ctx c return err } - allRecipients := input.Recipients - return sie.client.sender.Send( ctx, NewEmail(). - WithToAddresses(allRecipients). // TODO: NJD cc and/or bcc? + WithToAddresses([]models.EmailAddress{input.Recipient}). WithSubject(subject). WithBody(body), ) diff --git a/pkg/email/grb_review_discussion_individual_tagged_test.go b/pkg/email/grb_review_discussion_individual_tagged_test.go index d4fb7d5d4f..6f02d79277 100644 --- a/pkg/email/grb_review_discussion_individual_tagged_test.go +++ b/pkg/email/grb_review_discussion_individual_tagged_test.go @@ -36,7 +36,6 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotificati sender := mockSender{} recipient := models.NewEmailAddress("fake@fake.com") - recipients := []models.EmailAddress{recipient} input := SendGRBReviewDiscussionIndividualTaggedEmailInput{ SystemIntakeID: intakeID, @@ -48,7 +47,7 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotificati DiscussionContent: discussionContent, DiscussionLink: discussionLink, ITGovernanceInboxAddress: ITGovInboxAddress, - Recipients: recipients, + Recipient: recipient, } client, err := NewClient(s.config, &sender) @@ -68,7 +67,7 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotificati

      Discussion

      -
      +

      %s

      %s

      %s

      @@ -78,10 +77,10 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotificati

      +

      If you have questions, please contact the Governance Team at %s.


      -

      You will continue to receive email notifications about this request until it is closed.

      `, userName, @@ -118,3 +117,103 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotificati s.EqualHTML(expectedEmail, sender.body) }) } + +func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotificationAdmin() { + ctx := context.Background() + intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + userName := "Rock Lee" + requestName := "Salad/Sandwich Program" + discussionBoardType := "Internal GRB Discussion Board" + role := "" // empty to signify admin + discussionContent := template.HTML(`

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `) + + grbReviewLink := fmt.Sprintf( + "%s://%s/it-governance/%s/grb-review", + s.config.URLScheme, + s.config.URLHost, + intakeID.String(), + ) + + discussionLink := fmt.Sprintf( + "%s://%s/it-governance/%s/grb-review/discussionID=BLAH", + s.config.URLScheme, + s.config.URLHost, + intakeID.String(), + ) + ITGovInboxAddress := s.config.GRTEmail.String() + + sender := mockSender{} + recipient := models.NewEmailAddress("fake@fake.com") + + input := SendGRBReviewDiscussionIndividualTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + DiscussionBoardType: discussionBoardType, + GRBReviewLink: grbReviewLink, + Role: role, + DiscussionContent: discussionContent, + DiscussionLink: discussionLink, + ITGovernanceInboxAddress: ITGovInboxAddress, + Recipient: recipient, + } + + client, err := NewClient(s.config, &sender) + s.NoError(err) + + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail(ctx, input) + s.NoError(err) + + getExpectedEmail := func() string { + return fmt.Sprintf(` +

      EASi

      +

      Easy Access to System Information

      + +

      %s tagged you in the %s for %s.

      + +

      View this request in EASi

      +
      + +

      Discussion

      +
      +

      %s

      +

      Governance Admin Team

      +

      %s

      +

      + + Reply in EASi + +

      + +
      +
      +

      You will continue to receive email notifications about this request until it is closed.

      + `, + userName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + discussionContent, + discussionLink, + ) + } + + expectedEmail := getExpectedEmail() + expectedSubject := "You were tagged in a GRB Review discussion for " + requestName + + s.Run("Subject is correct", func() { + s.Equal(expectedSubject, sender.subject) + + }) + + s.Run("Recipient is correct", func() { + s.Equal(sender.toAddresses[0], recipient) + s.Empty(sender.ccAddresses) + s.Empty(sender.bccAddresses) + }) + + s.Run("all info is included", func() { + s.EqualHTML(expectedEmail, sender.body) + }) +} diff --git a/pkg/email/grb_review_discussion_reply.go b/pkg/email/grb_review_discussion_reply.go index 4168ef59bf..753dbb8cc5 100644 --- a/pkg/email/grb_review_discussion_reply.go +++ b/pkg/email/grb_review_discussion_reply.go @@ -23,7 +23,7 @@ type SendGRBReviewDiscussionReplyEmailInput struct { DiscussionContent template.HTML DiscussionLink string ITGovernanceInboxAddress string - Recipients []models.EmailAddress + Recipient models.EmailAddress } // GRBReviewDiscussionReplyBody contains the data needed for interpolation in @@ -37,25 +37,31 @@ type GRBReviewDiscussionReplyBody struct { DiscussionContent template.HTML DiscussionLink string ITGovernanceInboxAddress string + IsAdmin bool } func (sie systemIntakeEmails) grbReviewDiscussionReplyBody(input SendGRBReviewDiscussionReplyEmailInput) (string, error) { + if sie.client.templates.grbReviewDiscussionReply == nil { + return "", errors.New("grb review discussion reply template is nil") + } + grbReviewPath := path.Join("it-governance", input.SystemIntakeID.String(), "grb-review") grbDiscussionPath := path.Join(grbReviewPath, "discussionID=BLAH") // TODO: NJD add actual discussion ID field + role := input.Role + if len(role) < 1 { + role = "Governance Admin Team" + } data := GRBReviewDiscussionReplyBody{ UserName: input.UserName, RequestName: input.RequestName, DiscussionBoardType: input.DiscussionBoardType, GRBReviewLink: sie.client.urlFromPath(grbReviewPath), - Role: input.Role, + Role: role, DiscussionContent: input.DiscussionContent, DiscussionLink: sie.client.urlFromPath(grbDiscussionPath), ITGovernanceInboxAddress: input.ITGovernanceInboxAddress, - } - - if sie.client.templates.grbReviewDiscussionReply == nil { - return "", errors.New("grb review discussion reply template is nil") + IsAdmin: len(input.Role) < 1, } var b bytes.Buffer @@ -76,12 +82,10 @@ func (sie systemIntakeEmails) SendGRBReviewDiscussionReplyEmail(ctx context.Cont return err } - allRecipients := input.Recipients - return sie.client.sender.Send( ctx, NewEmail(). - WithToAddresses(allRecipients). // TODO: NJD cc and/or bcc? + WithToAddresses([]models.EmailAddress{input.Recipient}). WithSubject(subject). WithBody(body), ) diff --git a/pkg/email/grb_review_discussion_reply_test.go b/pkg/email/grb_review_discussion_reply_test.go index 83998a0084..879daebb12 100644 --- a/pkg/email/grb_review_discussion_reply_test.go +++ b/pkg/email/grb_review_discussion_reply_test.go @@ -36,7 +36,6 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionReplyNotification() { sender := mockSender{} recipient := models.NewEmailAddress("fake@fake.com") - recipients := []models.EmailAddress{recipient} input := SendGRBReviewDiscussionReplyEmailInput{ SystemIntakeID: intakeID, @@ -48,7 +47,7 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionReplyNotification() { DiscussionContent: discussionContent, DiscussionLink: discussionLink, ITGovernanceInboxAddress: ITGovInboxAddress, - Recipients: recipients, + Recipient: recipient, } client, err := NewClient(s.config, &sender) @@ -68,7 +67,7 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionReplyNotification() {

      Discussion

      -
      +

      %s

      %s

      %s

      @@ -78,10 +77,10 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionReplyNotification() {

      +

      If you have questions, please contact the Governance Team at %s.


      -

      You will continue to receive email notifications about this request until it is closed.

      `, userName, @@ -118,3 +117,106 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionReplyNotification() { s.EqualHTML(expectedEmail, sender.body) }) } + +func (s *EmailTestSuite) TestCreateGRBReviewDiscussionReplyNotificationAdmin() { + ctx := context.Background() + intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + userName := "Rock Lee" + requestName := "Salad/Sandwich Program" + discussionBoardType := "Internal GRB Discussion Board" + role := "" // empty to signify admin + discussionContent := template.HTML(`

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `) + + grbReviewLink := fmt.Sprintf( + "%s://%s/it-governance/%s/grb-review", + s.config.URLScheme, + s.config.URLHost, + intakeID.String(), + ) + + discussionLink := fmt.Sprintf( + "%s://%s/it-governance/%s/grb-review/discussionID=BLAH", + s.config.URLScheme, + s.config.URLHost, + intakeID.String(), + ) + ITGovInboxAddress := s.config.GRTEmail.String() + + sender := mockSender{} + recipient := models.NewEmailAddress("fake@fake.com") + + input := SendGRBReviewDiscussionReplyEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + DiscussionBoardType: discussionBoardType, + GRBReviewLink: grbReviewLink, + Role: role, + DiscussionContent: discussionContent, + DiscussionLink: discussionLink, + ITGovernanceInboxAddress: ITGovInboxAddress, + Recipient: recipient, + } + + client, err := NewClient(s.config, &sender) + s.NoError(err) + + err = client.SystemIntake.SendGRBReviewDiscussionReplyEmail(ctx, input) + s.NoError(err) + + getExpectedEmail := func() string { + return fmt.Sprintf(` +

      EASi

      +

      Easy Access to System Information

      + +

      %s replied to your discussion on the %s for %s.

      + +

      View this request in EASi

      +
      + +

      Discussion

      +
      +

      %s

      +

      Governance Admin Team

      +

      %s

      +

      + + Reply in EASi + +

      + +
      +
      +

      You will continue to receive email notifications about this request until it is closed.

      + `, + userName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + discussionContent, + discussionLink, + ) + } + + expectedEmail := getExpectedEmail() + expectedSubject := "New reply to your discussion in the GRB Review for " + requestName + + s.Run("Subject is correct", func() { + s.Equal(expectedSubject, sender.subject) + + }) + + s.Run("Recipient is correct", func() { + allRecipients := []models.EmailAddress{ + recipient, + } + s.ElementsMatch(sender.toAddresses, allRecipients) + s.Empty(sender.ccAddresses) + s.Empty(sender.bccAddresses) + }) + + s.Run("all info is included", func() { + s.EqualHTML(expectedEmail, sender.body) + }) +} diff --git a/pkg/email/templates/easi_header.gohtml b/pkg/email/templates/easi_header.gohtml index bfb8af5d22..3656d6adbe 100644 --- a/pkg/email/templates/easi_header.gohtml +++ b/pkg/email/templates/easi_header.gohtml @@ -33,6 +33,10 @@ border-color: #bbb; border-radius: 2px; } + + .mention { + color: #005EA2; + } diff --git a/pkg/email/templates/grb_review_discussion_group_tagged.gohtml b/pkg/email/templates/grb_review_discussion_group_tagged.gohtml index c007ba9c58..13054bc3a7 100644 --- a/pkg/email/templates/grb_review_discussion_group_tagged.gohtml +++ b/pkg/email/templates/grb_review_discussion_group_tagged.gohtml @@ -6,7 +6,7 @@

      Discussion

      -
      +

      {{.UserName}}

      {{.Role}}

      {{.DiscussionContent}}

      @@ -16,8 +16,11 @@

      -
      -

      If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.

      -

      +{{if not .IsAdmin}} +
      +

      If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.

      +{{end}} +

      You will continue to receive email notifications about this request until it is closed.

      diff --git a/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml b/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml index c6bd562976..15260db93c 100644 --- a/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml +++ b/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml @@ -6,18 +6,21 @@

      Discussion

      -
      +

      {{.UserName}}

      {{.Role}}

      {{.DiscussionContent}}

      - - Reply in EASi - + + Reply in EASi +

      -
      -

      If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.

      -

      +{{if not .IsAdmin}} +
      +

      If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.

      +{{end}} +

      You will continue to receive email notifications about this request until it is closed.

      diff --git a/pkg/email/templates/grb_review_discussion_reply.gohtml b/pkg/email/templates/grb_review_discussion_reply.gohtml index 34367b2195..69ee7f64bd 100644 --- a/pkg/email/templates/grb_review_discussion_reply.gohtml +++ b/pkg/email/templates/grb_review_discussion_reply.gohtml @@ -6,18 +6,21 @@

      Discussion

      -
      +

      {{.UserName}}

      {{.Role}}

      {{.DiscussionContent}}

      - - Reply in EASi - + + Reply in EASi +

      -
      -

      If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.

      -

      -

      You will continue to receive email notifications about this request until it is closed.

      \ No newline at end of file +{{if not .IsAdmin}} +
      +

      If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.

      +{{end}} +
      +

      You will continue to receive email notifications about this request until it is closed.

      From 969297ffc0319f56a3fdd592a33054d1a1b8245a Mon Sep 17 00:00:00 2001 From: adamodd <97050498+adamodd@users.noreply.github.com> Date: Tue, 3 Dec 2024 13:30:28 -0800 Subject: [PATCH 14/22] EASI-4667 Side Panel Routing / State management (#2901) * DiscussionMode query handling * Update tests with MemoryRouter * Fix closure typo * DiscussionList keys * Create new, submit, go to discussions view * Open to start discussion view * `useDiscussion` hook * `discussionId` determines `activeDiscussion` * `useDiscussionParams` reorganize * Oops! * DiscussionForm -> Cancel -> Back to view mode * Keep extracted reference instead of redefining literals with `DiscussionMode` * Rename `useDiscussionParams.ts` * Finally push * Reset alert on mode change --- src/hooks/useDiscussionParams.ts | 72 +++++++++++++++++++ src/views/DiscussionBoard/Discussion.test.tsx | 33 +++++---- src/views/DiscussionBoard/Discussion.tsx | 3 - .../DiscussionBoard/ViewDiscussions.test.tsx | 19 ++++- src/views/DiscussionBoard/ViewDiscussions.tsx | 22 +++--- .../components/DiscussionForm.tsx | 14 +++- .../components/DiscussionPost/index.test.tsx | 33 ++++++--- .../components/DiscussionPost/index.tsx | 12 +++- src/views/DiscussionBoard/index.tsx | 71 +++++++++++------- .../GRBReview/Discussions.test.tsx | 25 ++++--- .../GRBReview/Discussions.tsx | 24 ++++--- 11 files changed, 233 insertions(+), 95 deletions(-) create mode 100644 src/hooks/useDiscussionParams.ts diff --git a/src/hooks/useDiscussionParams.ts b/src/hooks/useDiscussionParams.ts new file mode 100644 index 0000000000..5360847cb8 --- /dev/null +++ b/src/hooks/useDiscussionParams.ts @@ -0,0 +1,72 @@ +import { useHistory, useLocation } from 'react-router-dom'; + +const discussionModeKeys = ['view', 'start', 'reply'] as const; + +export type DiscussionMode = (typeof discussionModeKeys)[number]; + +/** + * Handle Discussion (side panel modal) state with the url query params + * `discussionMode` and `discussionId`. + */ +export default function useDiscussionParams() { + const history = useHistory(); + const location = useLocation(); + + return { + getDiscussionParams(): { + /** Undefined implies a closed modal */ + discussionMode: DiscussionMode | undefined; + discussionId: string | undefined; + } { + const q = new URLSearchParams(location.search); + + const discussionMode = q.get('discussionMode') as DiscussionMode | null; + + // Silent ignore on invalid `discussionModeKeys` + if ( + discussionMode === null || + !discussionModeKeys.includes(discussionMode) + ) { + return { + discussionMode: undefined, + discussionId: undefined + }; + } + + // Check reply mode for valid `discussionId` + // Silent fail if `discussionId` is invalid + if (discussionMode === 'reply') { + const discussionId = q.get('discussionId'); + + if (discussionId === null) + return { + discussionMode: undefined, + discussionId: undefined + }; + + return { discussionMode, discussionId }; + } + + return { discussionMode, discussionId: undefined }; + }, + + /** Push a new url query to update the Discussion subviews state. `false` implies closing the modal */ + pushDiscussionQuery( + query: + | { discussionMode: Extract } + | { + discussionMode: Extract; + discussionId: string; + } + | false + ) { + if (query === false) { + history.push(`${location.pathname}`); + return; + } + + const querystring = new URLSearchParams(query); + history.push(`${location.pathname}?${querystring}`); + } + }; +} diff --git a/src/views/DiscussionBoard/Discussion.test.tsx b/src/views/DiscussionBoard/Discussion.test.tsx index 325dc89fbb..5441a22c03 100644 --- a/src/views/DiscussionBoard/Discussion.test.tsx +++ b/src/views/DiscussionBoard/Discussion.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { @@ -22,13 +23,15 @@ describe('Discussion component', () => { const [discussion] = discussions; render( - - - + + + + + ); expect( @@ -105,13 +108,15 @@ describe('Discussion component', () => { }; render( - - - + + + + + ); // Toggle view more replies diff --git a/src/views/DiscussionBoard/Discussion.tsx b/src/views/DiscussionBoard/Discussion.tsx index f0369504bb..9d3e269709 100644 --- a/src/views/DiscussionBoard/Discussion.tsx +++ b/src/views/DiscussionBoard/Discussion.tsx @@ -37,9 +37,7 @@ const Discussion = ({ return (

      {t('general.discussion')}

      - - {replies.length > 0 && ( <>
      @@ -74,7 +72,6 @@ const Discussion = ({ )} )} -

      {t('general.reply')}

      { ); it('renders the component', () => { - render(); + render( + + + + ); expect( screen.getByRole('heading', { @@ -41,7 +46,11 @@ describe('ViewDiscussions component', () => { }); it('renders alerts for no discussion posts', () => { - render(); + render( + + + + ); expect(screen.getByText(noNewDiscussionsText)).toBeInTheDocument(); @@ -61,7 +70,11 @@ describe('ViewDiscussions component', () => { discussion => discussion.replies.length === 0 ); - render(); + render( + + + + ); /* Discussions with replies */ diff --git a/src/views/DiscussionBoard/ViewDiscussions.tsx b/src/views/DiscussionBoard/ViewDiscussions.tsx index 14e4f92845..60eb065421 100644 --- a/src/views/DiscussionBoard/ViewDiscussions.tsx +++ b/src/views/DiscussionBoard/ViewDiscussions.tsx @@ -5,6 +5,7 @@ import { SystemIntakeGRBReviewDiscussionFragment } from 'gql/gen/graphql'; import Alert from 'components/shared/Alert'; import IconButton from 'components/shared/IconButton'; +import useDiscussionParams from 'hooks/useDiscussionParams'; import DiscussionPost from './components/DiscussionPost'; import DiscussionsList from './components/DiscussionsList'; @@ -22,6 +23,8 @@ type ViewDiscussionsProps = { const ViewDiscussions = ({ grbDiscussions }: ViewDiscussionsProps) => { const { t } = useTranslation('discussions'); + const { pushDiscussionQuery } = useDiscussionParams(); + const discussionsWithoutReplies: SystemIntakeGRBReviewDiscussionFragment[] = grbDiscussions.filter(discussion => discussion.replies.length === 0); @@ -36,18 +39,17 @@ const ViewDiscussions = ({ grbDiscussions }: ViewDiscussionsProps) => {

      {t('governanceReviewBoard.internal.description')}

      -

      {t('general.label')}

      null} + onClick={() => { + pushDiscussionQuery({ discussionMode: 'start' }); + }} icon={} unstyled > {t('general.startNewDiscussion')} - { {discussionsWithoutReplies.map((discussion, index) => (
    • { {discussionsWithReplies.map((discussion, index) => (
    • { if ('systemIntakeID' in mutationProps) { mutateDiscussion({ @@ -83,9 +86,10 @@ const DiscussionForm = ({ message: t('general.alerts.startDiscussionError'), type: 'error' }); + }) + .finally(() => { + pushDiscussionQuery({ discussionMode: 'view' }); }); - - // TODO: Go back to discussion board view } }); @@ -159,7 +163,11 @@ const DiscussionForm = ({ -
    • - {/** - * TODO: - * - Update to use TipTap text area - * - Truncate text after 3 lines with `Read more` button - */} - { diff --git a/src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx b/src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx index d40b7b4cc6..60d5dc2f92 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx @@ -1,10 +1,17 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { render, screen, within } from '@testing-library/react'; -import { SystemIntakeGRBReviewDiscussionFragment } from 'gql/gen/graphql'; +import { + GetSystemIntakeGRBDiscussionsDocument, + GetSystemIntakeGRBDiscussionsQuery, + GetSystemIntakeGRBDiscussionsQueryVariables, + SystemIntakeGRBReviewDiscussionFragment +} from 'gql/gen/graphql'; import { mockDiscussions } from 'data/mock/discussions'; import { systemIntake } from 'data/mock/systemIntake'; +import { MockedQuery } from 'types/util'; +import VerboseMockedProvider from 'utils/testing/VerboseMockedProvider'; import Discussions from './Discussions'; @@ -15,19 +22,44 @@ const discussionWithoutReplies: SystemIntakeGRBReviewDiscussionFragment = { replies: [] }; +const getSystemIntakeGRBDiscussions = ( + grbDiscussions: SystemIntakeGRBReviewDiscussionFragment[] +): MockedQuery< + GetSystemIntakeGRBDiscussionsQuery, + GetSystemIntakeGRBDiscussionsQueryVariables +> => ({ + request: { + query: GetSystemIntakeGRBDiscussionsDocument, + variables: { + id: systemIntake.id + } + }, + result: { + data: { + __typename: 'Query', + systemIntake: { + __typename: 'SystemIntake', + id: systemIntake.id, + grbDiscussions + } + } + } +}); + describe('Discussions', () => { - it('renders 0 discussions without replies', () => { + it('renders 0 discussions without replies', async () => { render( - + + + ); expect( - screen.getByRole('heading', { name: 'Most recent activity' }) + await screen.findByRole('heading', { name: 'Most recent activity' }) ).toBeInTheDocument(); expect( @@ -39,18 +71,19 @@ describe('Discussions', () => { expect(screen.queryByRole('button', { name: 'View' })).toBeNull(); }); - it('renders 1 discussion without replies', () => { + it('renders 1 discussion without replies', async () => { render( - + + + ); expect( - screen.getByText('1 discussion without replies') + await screen.findByText('1 discussion without replies') ).toBeInTheDocument(); expect( @@ -60,23 +93,25 @@ describe('Discussions', () => { expect(screen.getByRole('button', { name: 'View' })).toBeInTheDocument(); }); - it('renders discussion board with no discussions', () => { + it('renders discussion board with no discussions', async () => { render( - + + + ); - expect( - screen.queryByRole('heading', { name: 'Most recent activity' }) - ).toBeNull(); - - const noDiscussionsAlert = screen.getByTestId('alert'); + const noDiscussionsAlert = await screen.findByTestId('alert'); const startDiscussionButton = within(noDiscussionsAlert).getByRole( 'button', { name: 'Start a discussion' } ); expect(startDiscussionButton).toBeInTheDocument(); + + expect( + screen.queryByRole('heading', { name: 'Most recent activity' }) + ).toBeNull(); }); }); diff --git a/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx b/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx index 333acb8f70..e58121991a 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Button, Icon } from '@trussworks/react-uswds'; import classNames from 'classnames'; -import { SystemIntakeGRBReviewDiscussionFragment } from 'gql/gen/graphql'; +import { useGetSystemIntakeGRBDiscussionsQuery } from 'gql/gen/graphql'; import Alert from 'components/shared/Alert'; import CollapsableLink from 'components/shared/CollapsableLink'; @@ -13,18 +13,23 @@ import DiscussionPost from 'views/DiscussionBoard/components/DiscussionPost'; type DiscussionsProps = { systemIntakeID: string; - grbDiscussions: SystemIntakeGRBReviewDiscussionFragment[]; className?: string; }; /** Displays recent discussions on GRB Review tab */ -const Discussions = ({ - systemIntakeID, - grbDiscussions, - className -}: DiscussionsProps) => { +const Discussions = ({ systemIntakeID, className }: DiscussionsProps) => { const { t } = useTranslation('discussions'); + const { pushDiscussionQuery } = useDiscussionParams(); + + const { data } = useGetSystemIntakeGRBDiscussionsQuery({ + variables: { id: systemIntakeID } + }); + + const grbDiscussions = data?.systemIntake?.grbDiscussions; + + if (!grbDiscussions) return null; + const discussionsWithoutRepliesCount = grbDiscussions.filter( discussion => discussion.replies.length === 0 ).length; @@ -32,8 +37,6 @@ const Discussions = ({ const recentDiscussion = grbDiscussions.length > 0 ? grbDiscussions[0] : undefined; - const { pushDiscussionQuery } = useDiscussionParams(); - return ( <> ) : ( diff --git a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx index a62768b90e..67c11b7bae 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx @@ -152,7 +152,6 @@ const getSystemIntakeGRBReviewQuery = ( __typename: 'SystemIntake', id: systemIntake.id, grbReviewers: reviewer ? [reviewer] : [], - grbDiscussions: [], grbReviewStartedAt: null } } @@ -203,7 +202,6 @@ describe('GRB reviewer form', () => { {...systemIntake} businessCase={businessCase} grbReviewers={[]} - grbDiscussions={[]} /> @@ -213,7 +211,6 @@ describe('GRB reviewer form', () => { {...systemIntake} businessCase={businessCase} grbReviewers={[grbReviewer]} - grbDiscussions={[]} /> @@ -293,7 +290,6 @@ describe('GRB reviewer form', () => { {...systemIntake} businessCase={businessCase} grbReviewers={[grbReviewer]} - grbDiscussions={[]} /> @@ -303,7 +299,6 @@ describe('GRB reviewer form', () => { {...systemIntake} businessCase={businessCase} grbReviewers={[updatedGRBReviewer]} - grbDiscussions={[]} /> diff --git a/src/views/GovernanceReviewTeam/GRBReview/index.test.tsx b/src/views/GovernanceReviewTeam/GRBReview/index.test.tsx index c396214129..004b2cbbba 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/index.test.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/index.test.tsx @@ -29,7 +29,6 @@ describe('GRB review tab', () => { {...systemIntake} businessCase={businessCase} grbReviewers={[]} - grbDiscussions={[]} /> @@ -55,7 +54,6 @@ describe('GRB review tab', () => { {...systemIntake} businessCase={businessCase} grbReviewers={[]} - grbDiscussions={[]} /> @@ -83,7 +81,6 @@ describe('GRB review tab', () => { businessCase={businessCase} grbReviewers={[]} grbReviewStartedAt={date} - grbDiscussions={[]} /> @@ -136,7 +133,6 @@ describe('GRB review tab', () => { businessCase={businessCase} grbReviewers={grbReviewers} grbReviewStartedAt={null} - grbDiscussions={[]} /> diff --git a/src/views/GovernanceReviewTeam/GRBReview/index.tsx b/src/views/GovernanceReviewTeam/GRBReview/index.tsx index ed81a618bb..23ec2cd731 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/index.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/index.tsx @@ -13,7 +13,6 @@ import { } from '@trussworks/react-uswds'; import { GetSystemIntakeGRBReviewDocument, - SystemIntakeGRBReviewDiscussionFragment, SystemIntakeGRBReviewerFragment, useDeleteSystemIntakeGRBReviewerMutation, useStartGRBReviewMutation @@ -51,7 +50,6 @@ type GRBReviewProps = { businessCase: BusinessCaseModel; grbReviewers: SystemIntakeGRBReviewerFragment[]; documents: SystemIntakeDocument[]; - grbDiscussions: SystemIntakeGRBReviewDiscussionFragment[]; grbReviewStartedAt?: string | null; }; @@ -62,7 +60,6 @@ const GRBReview = ({ state, grbReviewers, documents, - grbDiscussions, grbReviewStartedAt }: GRBReviewProps) => { const { t } = useTranslation('grbReview'); @@ -364,7 +361,6 @@ const GRBReview = ({ diff --git a/src/views/GovernanceReviewTeam/RequestOverview.test.tsx b/src/views/GovernanceReviewTeam/RequestOverview.test.tsx index cfdf4358dc..a821768223 100644 --- a/src/views/GovernanceReviewTeam/RequestOverview.test.tsx +++ b/src/views/GovernanceReviewTeam/RequestOverview.test.tsx @@ -137,7 +137,7 @@ describe('Governance Review Team', () => { - + @@ -159,7 +159,7 @@ describe('Governance Review Team', () => { - + @@ -186,7 +186,7 @@ describe('Governance Review Team', () => { - + @@ -205,7 +205,7 @@ describe('Governance Review Team', () => { - + @@ -225,7 +225,7 @@ describe('Governance Review Team', () => { - + @@ -247,7 +247,7 @@ describe('Governance Review Team', () => { - + @@ -271,7 +271,7 @@ describe('Governance Review Board', () => { - + diff --git a/src/views/GovernanceReviewTeam/RequestOverview.tsx b/src/views/GovernanceReviewTeam/RequestOverview.tsx index 283696cc63..39de4471a2 100644 --- a/src/views/GovernanceReviewTeam/RequestOverview.tsx +++ b/src/views/GovernanceReviewTeam/RequestOverview.tsx @@ -6,10 +6,7 @@ import { Route, Switch, useParams } from 'react-router-dom'; import { useQuery } from '@apollo/client'; import { Grid } from '@trussworks/react-uswds'; import classnames from 'classnames'; -import { - SystemIntakeGRBReviewDiscussionFragment, - SystemIntakeGRBReviewerFragment -} from 'gql/gen/graphql'; +import { SystemIntakeGRBReviewerFragment } from 'gql/gen/graphql'; import { useFlags } from 'launchdarkly-react-client-sdk'; import MainContent from 'components/MainContent'; @@ -48,13 +45,11 @@ import './index.scss'; type RequestOverviewProps = { grbReviewers: SystemIntakeGRBReviewerFragment[]; grbReviewStartedAt?: string | null; - grbDiscussions: SystemIntakeGRBReviewDiscussionFragment[]; }; const RequestOverview = ({ grbReviewers, - grbReviewStartedAt, - grbDiscussions + grbReviewStartedAt }: RequestOverviewProps) => { const { t } = useTranslation('governanceReviewTeam'); const flags = useFlags(); @@ -216,7 +211,6 @@ const RequestOverview = ({ businessCase={businessCase} grbReviewers={grbReviewers} grbReviewStartedAt={grbReviewStartedAt} - grbDiscussions={grbDiscussions} /> )} /> diff --git a/src/views/GovernanceReviewTeam/index.tsx b/src/views/GovernanceReviewTeam/index.tsx index 58537b5cc3..a06c90d064 100644 --- a/src/views/GovernanceReviewTeam/index.tsx +++ b/src/views/GovernanceReviewTeam/index.tsx @@ -30,8 +30,7 @@ const GovernanceReviewTeam = () => { } }); - const { grbReviewers, grbReviewStartedAt, grbDiscussions } = - data?.systemIntake || {}; + const { grbReviewers, grbReviewStartedAt } = data?.systemIntake || {}; /** Check if current user is set as GRB reviewer */ const isGrbReviewer: boolean = useMemo(() => { @@ -63,7 +62,6 @@ const GovernanceReviewTeam = () => { diff --git a/src/views/GovernanceReviewTeam/subNavItems.ts b/src/views/GovernanceReviewTeam/subNavItems.ts index b1a80bc0aa..1f57a92d06 100644 --- a/src/views/GovernanceReviewTeam/subNavItems.ts +++ b/src/views/GovernanceReviewTeam/subNavItems.ts @@ -37,13 +37,13 @@ const subNavItems = ( route: `/it-governance/${systemId}/grb-review#documents`, text: 'grbReview:supportingDocuments' }, - { - route: `/it-governance/${systemId}/grb-review#participants`, - text: 'grbReview:participants' - }, { route: `/it-governance/${systemId}/grb-review#discussions`, text: 'discussions:general.label' + }, + { + route: `/it-governance/${systemId}/grb-review#participants`, + text: 'grbReview:participants' } ] }, From 6b1363c6f6f2e30a57750c1d729878e0dc72b733 Mon Sep 17 00:00:00 2001 From: Lee Warrick <32332479+mynar7@users.noreply.github.com> Date: Fri, 6 Dec 2024 10:39:19 -0500 Subject: [PATCH 16/22] [EASI-4635] Discussions API tests (#2908) add tests for discussion API --- pkg/graph/resolvers/resolver_test.go | 18 +- .../system_intake_grb_discussions_test.go | 484 ++++++++++++++++++ .../resolvers/system_intake_grb_reviewer.go | 12 + pkg/graph/schema.resolvers.go | 6 +- pkg/models/system_intake_grb_discussions.go | 7 + .../system_intake_grb_discussions_test.go | 219 ++++++++ 6 files changed, 735 insertions(+), 11 deletions(-) create mode 100644 pkg/graph/resolvers/system_intake_grb_discussions_test.go create mode 100644 pkg/models/system_intake_grb_discussions_test.go diff --git a/pkg/graph/resolvers/resolver_test.go b/pkg/graph/resolvers/resolver_test.go index cb0dbcc983..4706b23e21 100644 --- a/pkg/graph/resolvers/resolver_test.go +++ b/pkg/graph/resolvers/resolver_test.go @@ -48,7 +48,7 @@ func (s *ResolverSuite) SetupTest() { assert.NoError(s.T(), err) // Get the user account from the DB fresh for each test - princ := s.getTestPrincipal(s.testConfigs.Context, s.testConfigs.Store, s.testConfigs.UserInfo.Username) + princ := s.getTestPrincipal(s.testConfigs.Context, s.testConfigs.Store, s.testConfigs.UserInfo.Username, true) s.testConfigs.Principal = princ // get new dataloaders to clear any existing cached data @@ -62,6 +62,11 @@ func (s *ResolverSuite) SetupTest() { s.testConfigs.Sender.Clear() } +func (s *ResolverSuite) getTestContextWithPrincipal(euaID string, isAdmin bool) (context.Context, *authentication.EUAPrincipal) { + princ := s.getTestPrincipal(s.testConfigs.Context, s.testConfigs.Store, euaID, isAdmin) + return appcontext.WithPrincipal(s.testConfigs.Context, princ), princ +} + // TestResolverSuite runs the resolver test suite func TestResolverSuite(t *testing.T) { rs := new(ResolverSuite) @@ -174,15 +179,16 @@ func NewEmailClient() (*email.Client, *mockSender) { } // getTestPrincipal gets a user principal from database -func (s *ResolverSuite) getTestPrincipal(ctx context.Context, store *storage.Store, userName string) *authentication.EUAPrincipal { +func (s *ResolverSuite) getTestPrincipal(ctx context.Context, store *storage.Store, userName string, isAdmin bool) *authentication.EUAPrincipal { userAccount, _ := userhelpers.GetOrCreateUserAccount(ctx, store, store, userName, true, userhelpers.GetUserInfoAccountInfoWrapperFunc(s.testConfigs.UserSearchClient.FetchUserInfo)) princ := &authentication.EUAPrincipal{ - EUAID: userName, - JobCodeEASi: true, - JobCodeGRT: true, - UserAccount: userAccount, + EUAID: userName, + JobCodeEASi: true, + JobCodeGRT: isAdmin, + JobCodeTRBAdmin: isAdmin, + UserAccount: userAccount, } return princ diff --git a/pkg/graph/resolvers/system_intake_grb_discussions_test.go b/pkg/graph/resolvers/system_intake_grb_discussions_test.go new file mode 100644 index 0000000000..5e042c8bee --- /dev/null +++ b/pkg/graph/resolvers/system_intake_grb_discussions_test.go @@ -0,0 +1,484 @@ +package resolvers + +import ( + "context" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/email" + "github.com/cms-enterprise/easi-app/pkg/models" + "github.com/cms-enterprise/easi-app/pkg/userhelpers" +) + +func (s *ResolverSuite) TestSystemIntakeGRBDiscussions() { + store := s.testConfigs.Store + + s.Run("create and retrieve initial discussion", func() { + emailClient, _ := NewEmailClient() + + intake := s.createNewIntake() + ctx, princ := s.getTestContextWithPrincipal("ABCD", true) + post, err := CreateSystemIntakeGRBDiscussionPost( + ctx, + store, + emailClient, + models.CreateSystemIntakeGRBDiscussionPostInput{ + SystemIntakeID: intake.ID, + Content: models.TaggedHTML{ + RawContent: "

      banana

      ", + }, + }, + ) + s.NotNil(post) + s.NoError(err) + s.Equal(post.Content, models.HTML("

      banana

      ")) + s.Equal(post.SystemIntakeID, intake.ID) + s.Equal(princ.UserAccount.ID, post.CreatedBy) + // initial discussions should have no reply ID + s.Nil(post.ReplyToID) + + // test the resolver for retrieving discussions + discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) + s.NotNil(discussions) + s.NoError(err) + s.Len(discussions, 1) + s.Equal(discussions[0].InitialPost.ID, post.ID) + s.Equal(discussions[0].InitialPost.CreatedBy, post.CreatedBy) + s.Equal(discussions[0].InitialPost.Content, post.Content) + s.Len(discussions[0].Replies, 0) + }) + + s.Run("create GRB discussion and add to intake as admin", func() { + emailClient, _ := NewEmailClient() + + intake := s.createNewIntake() + ctx, princ := s.getTestContextWithPrincipal("ABCD", true) + post, err := CreateSystemIntakeGRBDiscussionPost( + ctx, + store, + emailClient, + models.CreateSystemIntakeGRBDiscussionPostInput{ + SystemIntakeID: intake.ID, + Content: models.TaggedHTML{ + RawContent: "

      banana

      ", + }, + }, + ) + s.NotNil(post) + s.NoError(err) + s.Equal(post.Content, models.HTML("

      banana

      ")) + s.Equal(post.SystemIntakeID, intake.ID) + s.Equal(princ.UserAccount.ID, post.CreatedBy) + // initial discussions should have no reply ID + s.Nil(post.ReplyToID) + }) + + s.Run("create GRB discussion and add to intake as reviewer", func() { + emailClient, _ := NewEmailClient() + + intake := s.createNewIntake() + + _, err := CreateSystemIntakeGRBReviewers( + s.testConfigs.Context, + store, + emailClient, + userhelpers.GetUserInfoAccountInfosWrapperFunc(s.testConfigs.UserSearchClient.FetchUserInfos), + &models.CreateSystemIntakeGRBReviewersInput{ + SystemIntakeID: intake.ID, + Reviewers: []*models.CreateGRBReviewerInput{ + { + EuaUserID: "ABCD", + VotingRole: models.SystemIntakeGRBReviewerVotingRoleVoting, + GrbRole: models.SystemIntakeGRBReviewerRoleCoChairCfo, + }, + }, + }, + ) + s.NoError(err) + + ctx, princ := s.getTestContextWithPrincipal("ABCD", false) + post, err := CreateSystemIntakeGRBDiscussionPost( + ctx, + store, + emailClient, + models.CreateSystemIntakeGRBDiscussionPostInput{ + SystemIntakeID: intake.ID, + Content: models.TaggedHTML{ + RawContent: "

      banana

      ", + }, + }, + ) + s.NotNil(post) + s.NoError(err) + s.Equal(post.Content, models.HTML("

      banana

      ")) + s.Equal(post.SystemIntakeID, intake.ID) + s.Equal(princ.UserAccount.ID, post.CreatedBy) + // initial discussions should have no reply ID + s.Nil(post.ReplyToID) + }) + + s.Run("cannot create GRB discussion if not reviewer or admin", func() { + emailClient, _ := NewEmailClient() + + intake := s.createNewIntake() + ctx, _ := s.getTestContextWithPrincipal("ABCD", false) + post, err := CreateSystemIntakeGRBDiscussionPost( + ctx, + store, + emailClient, + models.CreateSystemIntakeGRBDiscussionPostInput{ + SystemIntakeID: intake.ID, + Content: models.TaggedHTML{ + RawContent: "

      banana

      ", + }, + }, + ) + s.Nil(post) + s.Error(err) + }) +} + +func (s *ResolverSuite) TestSystemIntakeGRBDiscussionReplies() { + store := s.testConfigs.Store + + // helper to create an intake and add GRB reviewers at the same time + createIntakeAndAddReviewers := func(reviewerEuaIDs ...string) *models.SystemIntake { + intake := s.createNewIntake() + + reviewers := []*models.CreateGRBReviewerInput{} + for _, reviewerEUA := range reviewerEuaIDs { + reviewers = append(reviewers, &models.CreateGRBReviewerInput{ + EuaUserID: reviewerEUA, + VotingRole: models.SystemIntakeGRBReviewerVotingRoleVoting, + GrbRole: models.SystemIntakeGRBReviewerRoleCoChairCfo, + }) + } + + if len(reviewers) > 0 { + _, err := CreateSystemIntakeGRBReviewers( + s.testConfigs.Context, + store, + nil, //email client + userhelpers.GetUserInfoAccountInfosWrapperFunc(s.testConfigs.UserSearchClient.FetchUserInfos), + &models.CreateSystemIntakeGRBReviewersInput{ + SystemIntakeID: intake.ID, + Reviewers: reviewers, + }, + ) + s.NoError(err) + } + return intake + } + + // helper to create a discussion + createDiscussion := func( + ctx context.Context, + emailClient *email.Client, + intakeID uuid.UUID, + content string, + ) *models.SystemIntakeGRBReviewDiscussionPost { + discussion, err := CreateSystemIntakeGRBDiscussionPost( + ctx, + store, + emailClient, + models.CreateSystemIntakeGRBDiscussionPostInput{ + SystemIntakeID: intakeID, + Content: models.TaggedHTML{ + RawContent: models.HTML(content), + }, + }, + ) + s.NotNil(discussion) + s.NoError(err) + s.Equal(discussion.Content, models.HTML(content)) + s.Equal(discussion.SystemIntakeID, intakeID) + s.NotNil(discussion.ID) + s.Nil(discussion.ReplyToID) + return discussion + } + + s.Run("reply to GRB discussion as admin", func() { + emailClient, _ := NewEmailClient() + + intake := createIntakeAndAddReviewers() + + ctx, princ := s.getTestContextWithPrincipal("USR1", true) + discussionPost := createDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") + + replyPost, err := CreateSystemIntakeGRBDiscussionReply( + ctx, + store, + emailClient, + models.CreateSystemIntakeGRBDiscussionReplyInput{ + InitialPostID: discussionPost.ID, + Content: models.TaggedHTML{ + RawContent: "

      banana

      ", + }, + }, + ) + // test returned reply post + s.NotNil(replyPost) + s.NoError(err) + s.Equal(replyPost.Content, models.HTML("

      banana

      ")) + s.Equal(replyPost.SystemIntakeID, intake.ID) + s.Equal(princ.UserAccount.ID, replyPost.CreatedBy) + s.NotNil(replyPost.ReplyToID) + s.Equal(*replyPost.ReplyToID, discussionPost.ID) + + // fetch discussion using resolver + discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) + s.NoError(err) + s.NotNil(discussions) + s.Len(discussions, 1) + discussion := discussions[0] + s.Equal(discussion.InitialPost.ID, discussionPost.ID) + + // test discussion reply from resolver + s.Len(discussions[0].Replies, 1) + reply := discussions[0].Replies[0] + s.Equal(princ.UserAccount.ID, reply.CreatedBy) + s.NotNil(reply.ReplyToID) + s.Equal(discussion.InitialPost.ID, *reply.ReplyToID) + s.Equal(reply.Content, models.HTML("

      banana

      ")) + }) + + s.Run("reply to GRB discussion as reviewer", func() { + emailClient, _ := NewEmailClient() + + intake := createIntakeAndAddReviewers("BTMN") + + ctx, discAuthor := s.getTestContextWithPrincipal("USR1", true) + discussionPost := createDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") + + ctx, replyAuthor := s.getTestContextWithPrincipal("BTMN", false) + replyPost, err := CreateSystemIntakeGRBDiscussionReply( + ctx, + store, + emailClient, + models.CreateSystemIntakeGRBDiscussionReplyInput{ + InitialPostID: discussionPost.ID, + Content: models.TaggedHTML{ + RawContent: "

      banana

      ", + }, + }, + ) + // test returned reply post + s.NotNil(replyPost) + s.NoError(err) + s.Equal(replyPost.Content, models.HTML("

      banana

      ")) + s.Equal(replyPost.SystemIntakeID, intake.ID) + s.Equal(replyAuthor.UserAccount.ID, replyPost.CreatedBy) + s.NotNil(replyPost.ReplyToID) + s.Equal(*replyPost.ReplyToID, discussionPost.ID) + + // fetch discussion using resolver + discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) + s.NoError(err) + s.NotNil(discussions) + s.Len(discussions, 1) + discussion := discussions[0] + s.Equal(discussion.InitialPost.ID, discussionPost.ID) + s.Equal(discussion.InitialPost.CreatedBy, discAuthor.UserAccount.ID) + + // test discussion reply from resolver + s.Len(discussions[0].Replies, 1) + reply := discussions[0].Replies[0] + s.Equal(replyAuthor.UserAccount.ID, reply.CreatedBy) + s.NotNil(reply.ReplyToID) + s.Equal(discussion.InitialPost.ID, *reply.ReplyToID) + s.Equal(reply.Content, models.HTML("

      banana

      ")) + }) + + s.Run("should not allow reply to GRB discussion when not reviewer nor admin", func() { + emailClient, _ := NewEmailClient() + + intake := createIntakeAndAddReviewers() + + ctx, discAuthor := s.getTestContextWithPrincipal("USR1", true) + discussionPost := createDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") + + ctx, _ = s.getTestContextWithPrincipal("USR2", false) + replyPost, err := CreateSystemIntakeGRBDiscussionReply( + ctx, + store, + emailClient, + models.CreateSystemIntakeGRBDiscussionReplyInput{ + InitialPostID: discussionPost.ID, + Content: models.TaggedHTML{ + RawContent: "

      banana

      ", + }, + }, + ) + s.Nil(replyPost) + s.Error(err) + + // fetch discussion using resolver + discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) + s.NoError(err) + s.NotNil(discussions) + s.Len(discussions, 1) + discussion := discussions[0] + s.Equal(discussion.InitialPost.ID, discussionPost.ID) + s.Equal(discussion.InitialPost.CreatedBy, discAuthor.UserAccount.ID) + + // discussion should have no replies + s.Len(discussions[0].Replies, 0) + }) + + s.Run("Should allow for multiple replies", func() { + emailClient, _ := NewEmailClient() + + intake := createIntakeAndAddReviewers("BTMN", "ABCD") + + ctx, discAuthor := s.getTestContextWithPrincipal("USR1", true) + discussionPost := createDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") + + ctx, reply1Author := s.getTestContextWithPrincipal("BTMN", false) + reply1Post, err := CreateSystemIntakeGRBDiscussionReply( + ctx, + store, + emailClient, + models.CreateSystemIntakeGRBDiscussionReplyInput{ + InitialPostID: discussionPost.ID, + Content: models.TaggedHTML{ + RawContent: "

      banana

      ", + }, + }, + ) + s.NotNil(reply1Post) + s.NoError(err) + s.Equal(reply1Post.Content, models.HTML("

      banana

      ")) + s.Equal(reply1Post.SystemIntakeID, intake.ID) + s.Equal(reply1Author.UserAccount.ID, reply1Post.CreatedBy) + s.NotNil(reply1Post.ReplyToID) + s.Equal(*reply1Post.ReplyToID, discussionPost.ID) + + ctx, reply2Author := s.getTestContextWithPrincipal("ABCD", false) + reply2Post, err := CreateSystemIntakeGRBDiscussionReply( + ctx, + store, + emailClient, + models.CreateSystemIntakeGRBDiscussionReplyInput{ + InitialPostID: discussionPost.ID, + Content: models.TaggedHTML{ + RawContent: "

      tangerine

      ", + }, + }, + ) + // test reply from mutation + s.NotNil(reply2Post) + s.NoError(err) + s.Equal(reply2Post.Content, models.HTML("

      tangerine

      ")) + s.Equal(reply2Post.SystemIntakeID, intake.ID) + s.Equal(reply2Author.UserAccount.ID, reply2Post.CreatedBy) + s.NotNil(reply2Post.ReplyToID) + s.Equal(*reply2Post.ReplyToID, discussionPost.ID) + + // fetch discussion using resolver + discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) + s.NoError(err) + s.NotNil(discussions) + s.Len(discussions, 1) + discussion := discussions[0] + s.Equal(discussion.InitialPost.ID, discussionPost.ID) + s.Equal(discussion.InitialPost.CreatedBy, discAuthor.UserAccount.ID) + + // test replies + s.Len(discussions[0].Replies, 2) + + reply1 := discussions[0].Replies[0] + s.Equal(reply1Author.UserAccount.ID, reply1.CreatedBy) + s.NotNil(reply1.ReplyToID) + s.Equal(discussion.InitialPost.ID, *reply1.ReplyToID) + s.Equal(reply1.Content, models.HTML("

      banana

      ")) + + reply2 := discussions[0].Replies[1] + s.Equal(reply2Author.UserAccount.ID, reply2.CreatedBy) + s.NotNil(reply2.ReplyToID) + s.Equal(discussion.InitialPost.ID, *reply2.ReplyToID) + s.Equal(reply2.Content, models.HTML("

      tangerine

      ")) + }) + + s.Run("Should allow for replies on different discussions", func() { + emailClient, _ := NewEmailClient() + + intake := createIntakeAndAddReviewers("BTMN", "ABCD") + + // create two discussions + ctx, disc1Author := s.getTestContextWithPrincipal("USR1", true) + discussion1Post := createDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") + + ctx, disc2Author := s.getTestContextWithPrincipal("USR2", true) + discussion2Post := createDiscussion(ctx, emailClient, intake.ID, "

      this is a second discussion

      ") + + // reply to the first discussion + ctx, reply1Author := s.getTestContextWithPrincipal("BTMN", false) + reply1Post, err := CreateSystemIntakeGRBDiscussionReply( + ctx, + store, + emailClient, + models.CreateSystemIntakeGRBDiscussionReplyInput{ + InitialPostID: discussion1Post.ID, + Content: models.TaggedHTML{ + RawContent: "

      banana

      ", + }, + }, + ) + s.NotNil(reply1Post) + s.NoError(err) + s.Equal(reply1Post.Content, models.HTML("

      banana

      ")) + s.Equal(reply1Post.SystemIntakeID, intake.ID) + s.Equal(reply1Author.UserAccount.ID, reply1Post.CreatedBy) + s.NotNil(reply1Post.ReplyToID) + s.Equal(*reply1Post.ReplyToID, discussion1Post.ID) + + // reply to the second discussion + ctx, reply2Author := s.getTestContextWithPrincipal("ABCD", false) + reply2Post, err := CreateSystemIntakeGRBDiscussionReply( + ctx, + store, + emailClient, + models.CreateSystemIntakeGRBDiscussionReplyInput{ + InitialPostID: discussion2Post.ID, + Content: models.TaggedHTML{ + RawContent: "

      tangerine

      ", + }, + }, + ) + s.NotNil(reply2Post) + s.NoError(err) + s.Equal(reply2Post.Content, models.HTML("

      tangerine

      ")) + s.Equal(reply2Post.SystemIntakeID, intake.ID) + s.Equal(reply2Author.UserAccount.ID, reply2Post.CreatedBy) + s.NotNil(reply2Post.ReplyToID) + s.Equal(*reply2Post.ReplyToID, discussion2Post.ID) + + // fetch discussions using resolver + discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) + s.NoError(err) + s.NotNil(discussions) + s.Len(discussions, 2) + discussion1 := discussions[0] + discussion2 := discussions[1] + s.Equal(discussion1.InitialPost.ID, discussion1Post.ID) + s.Equal(discussion2.InitialPost.ID, discussion2Post.ID) + s.Equal(discussion1.InitialPost.CreatedBy, disc1Author.UserAccount.ID) + s.Equal(discussion2.InitialPost.CreatedBy, disc2Author.UserAccount.ID) + + // test each discussion's reply + s.Len(discussions[0].Replies, 1) + s.Len(discussions[1].Replies, 1) + + reply1 := discussion1.Replies[0] + s.Equal(reply1Author.UserAccount.ID, reply1.CreatedBy) + s.NotNil(reply1.ReplyToID) + s.Equal(discussion1.InitialPost.ID, *reply1.ReplyToID) + s.Equal(reply1.Content, models.HTML("

      banana

      ")) + + reply2 := discussion2.Replies[0] + s.Equal(reply2Author.UserAccount.ID, reply2.CreatedBy) + s.NotNil(reply2.ReplyToID) + s.Equal(discussion2.InitialPost.ID, *reply2.ReplyToID) + s.Equal(reply2.Content, models.HTML("

      tangerine

      ")) + }) +} diff --git a/pkg/graph/resolvers/system_intake_grb_reviewer.go b/pkg/graph/resolvers/system_intake_grb_reviewer.go index c2bbf6acda..e3175d878b 100644 --- a/pkg/graph/resolvers/system_intake_grb_reviewer.go +++ b/pkg/graph/resolvers/system_intake_grb_reviewer.go @@ -22,6 +22,18 @@ import ( "github.com/cms-enterprise/easi-app/pkg/userhelpers" ) +func SystemIntakeGRBDiscussions( + ctx context.Context, + store *storage.Store, + intakeID uuid.UUID, +) ([]*models.SystemIntakeGRBReviewDiscussion, error) { + posts, err := dataloaders.GetSystemIntakeGRBDiscussionPostsBySystemIntakeID(ctx, intakeID) + if err != nil { + return nil, err + } + return models.CreateGRBDiscussionsFromPosts(posts) +} + // CreateSystemIntakeGRBReviewers creates GRB Reviewers for a System Intake func CreateSystemIntakeGRBReviewers( ctx context.Context, diff --git a/pkg/graph/schema.resolvers.go b/pkg/graph/schema.resolvers.go index 6e25d0966c..9563653940 100644 --- a/pkg/graph/schema.resolvers.go +++ b/pkg/graph/schema.resolvers.go @@ -1924,11 +1924,7 @@ func (r *systemIntakeResolver) RelatedTRBRequests(ctx context.Context, obj *mode // GrbDiscussions is the resolver for the grbDiscussions field. func (r *systemIntakeResolver) GrbDiscussions(ctx context.Context, obj *models.SystemIntake) ([]*models.SystemIntakeGRBReviewDiscussion, error) { - posts, err := dataloaders.GetSystemIntakeGRBDiscussionPostsBySystemIntakeID(ctx, obj.ID) - if err != nil { - return nil, err - } - return models.CreateGRBDiscussionsFromPosts(posts) + return resolvers.SystemIntakeGRBDiscussions(ctx, r.store, obj.ID) } // DocumentType is the resolver for the documentType field. diff --git a/pkg/models/system_intake_grb_discussions.go b/pkg/models/system_intake_grb_discussions.go index 0a0cf8a035..6ea628c066 100644 --- a/pkg/models/system_intake_grb_discussions.go +++ b/pkg/models/system_intake_grb_discussions.go @@ -2,6 +2,7 @@ package models import ( "errors" + "slices" "github.com/google/uuid" ) @@ -53,6 +54,9 @@ func CreateGRBDiscussionsFromPosts(posts []*SystemIntakeGRBReviewDiscussionPost) } discussions = append(discussions, groupedPosts) } + slices.SortFunc(discussions, func(a *SystemIntakeGRBReviewDiscussion, b *SystemIntakeGRBReviewDiscussion) int { + return a.InitialPost.CreatedAt.Compare(b.InitialPost.CreatedAt) + }) return discussions, nil } @@ -73,6 +77,9 @@ func CreateGRBDiscussionFromPosts(posts []*SystemIntakeGRBReviewDiscussionPost) if discussion.InitialPost == nil { return nil, errors.New("initial post not found") } + slices.SortFunc(discussion.Replies, func(a *SystemIntakeGRBReviewDiscussionPost, b *SystemIntakeGRBReviewDiscussionPost) int { + return a.CreatedAt.Compare(b.CreatedAt) + }) return &discussion, nil } diff --git a/pkg/models/system_intake_grb_discussions_test.go b/pkg/models/system_intake_grb_discussions_test.go new file mode 100644 index 0000000000..5f2a37e9c9 --- /dev/null +++ b/pkg/models/system_intake_grb_discussions_test.go @@ -0,0 +1,219 @@ +package models + +import ( + "github.com/google/uuid" +) + +func (s *ModelTestSuite) TestSystemIntakeGRBDiscussionsHelpers() { + createdByID := uuid.New() + + // initial post + post1 := NewSystemIntakeGRBReviewDiscussionPost(createdByID) + post1ID := uuid.New() + post1.ID = post1ID + + // replies + post1reply1 := NewSystemIntakeGRBReviewDiscussionPost(createdByID) + post1reply1ID := uuid.New() + post1reply1.ID = post1reply1ID + post1reply1.ReplyToID = &post1ID + post1reply2 := NewSystemIntakeGRBReviewDiscussionPost(createdByID) + post1reply2ID := uuid.New() + post1reply2.ID = post1reply2ID + post1reply2.ReplyToID = &post1ID + + // initial post + post2 := NewSystemIntakeGRBReviewDiscussionPost(createdByID) + post2ID := uuid.New() + post2.ID = post2ID + + // replies + post2reply1 := NewSystemIntakeGRBReviewDiscussionPost(createdByID) + post2reply1ID := uuid.New() + post2reply1.ID = post2reply1ID + post2reply1.ReplyToID = &post2ID + post2reply2 := NewSystemIntakeGRBReviewDiscussionPost(createdByID) + post2reply2ID := uuid.New() + post2reply2.ID = post2reply2ID + post2reply2.ReplyToID = &post2ID + + s.Run("CreateGRBDiscussionsFromPosts should sort initial posts and replies", func() { + // randomized order to reflect the possibility of posts not coming sorted from db + posts := []*SystemIntakeGRBReviewDiscussionPost{ + post2, + post1reply1, + post1, + post1reply2, + post2reply2, + post2reply1, + } + + discussions, err := CreateGRBDiscussionsFromPosts(posts) + s.NoError(err) + + s.NotNil(discussions) + s.Len(discussions, 2) + + // first discussion tests + firstDisc := discussions[0] + s.EqualValues( + firstDisc.InitialPost.ID, + post1ID, + "first discussion should be sorted as first item in slice", + ) + + // replies to first discussion + s.Len(firstDisc.Replies, 2) + + firstDiscFirstReply := firstDisc.Replies[0] + s.EqualValues( + firstDiscFirstReply.ID, + post1reply1ID, + "first reply to first discussion should be sorted as first reply", + ) + s.NotNil(firstDiscFirstReply.ReplyToID) + s.EqualValues( + *firstDiscFirstReply.ReplyToID, + post1ID, + "first reply to first discussion should have replyToID of first discussion post", + ) + + firstDiscSecondReply := firstDisc.Replies[1] + s.EqualValues( + firstDiscSecondReply.ID, + post1reply2ID, + "second reply to first discussion should be sorted as second reply", + ) + s.NotNil(firstDiscSecondReply.ReplyToID) + s.EqualValues( + *firstDiscSecondReply.ReplyToID, + post1ID, + "second reply to first discussion should have replyToID of first discussion post", + ) + + // second discussion tests + secondDisc := discussions[1] + s.EqualValues( + secondDisc.InitialPost.ID, + post2ID, + "second discussion should be sorted as second item in slice", + ) + + // replies to second discussion + s.Len(secondDisc.Replies, 2) + + secondDiscFirstReply := secondDisc.Replies[0] + s.EqualValues( + secondDiscFirstReply.ID, + post2reply1ID, + "first reply to second discussion should be sorted as first reply", + ) + s.NotNil(secondDiscFirstReply.ReplyToID) + s.EqualValues( + *secondDiscFirstReply.ReplyToID, + post2ID, + "first reply to second discussion should have replyToID of second discussion post", + ) + + secondDiscSecondReply := secondDisc.Replies[1] + s.EqualValues( + secondDiscSecondReply.ID, + post2reply2ID, + "second reply to second discussion should be sorted as second reply", + ) + s.NotNil(secondDiscSecondReply.ReplyToID) + s.EqualValues( + *secondDiscSecondReply.ReplyToID, + post2ID, + "second reply to second discussion should have replyToID of second discussion post", + ) + }) + + s.Run("CreateGRBDiscussionFromPosts should sort initial posts and replies", func() { + // randomized order to reflect the possibility of posts not coming sorted from db + posts := []*SystemIntakeGRBReviewDiscussionPost{ + post1reply1, + post1, + post1reply2, + } + + discussion, err := CreateGRBDiscussionFromPosts(posts) + s.NoError(err) + s.NotNil(discussion) + + s.EqualValues( + discussion.InitialPost.ID, + post1ID, + ) + + firstReply := discussion.Replies[0] + s.EqualValues( + firstReply.ID, + post1reply1ID, + "first reply to discussion should be sorted as first reply", + ) + s.NotNil(firstReply.ReplyToID) + s.EqualValues( + *firstReply.ReplyToID, + post1ID, + "first reply to discussion should have replyToID of discussion post", + ) + + secondReply := discussion.Replies[1] + s.EqualValues( + secondReply.ID, + post1reply2ID, + "second reply to discussion should be sorted as second reply", + ) + s.NotNil(secondReply.ReplyToID) + s.EqualValues( + *secondReply.ReplyToID, + post1ID, + "second reply to discussion should have replyToID of discussion post", + ) + }) + + s.Run("CreateGRBDiscussionFromPosts should error if two initial posts are found", func() { + // randomized order to reflect the possibility of posts not coming sorted from db + posts := []*SystemIntakeGRBReviewDiscussionPost{ + post2, + post1reply1, + post1, + post1reply2, + post2reply2, + post2reply1, + } + + discussion, err := CreateGRBDiscussionFromPosts(posts) + s.Error(err) + s.Nil(discussion) + }) + + s.Run("CreateGRBDiscussionFromPosts should error if no initial posts are found", func() { + // randomized order to reflect the possibility of posts not coming sorted from db + posts := []*SystemIntakeGRBReviewDiscussionPost{ + post1reply1, + post1reply2, + post2reply2, + post2reply1, + } + + discussion, err := CreateGRBDiscussionFromPosts(posts) + s.Error(err) + s.Nil(discussion) + }) + + s.Run("CreateGRBDiscussionsFromPosts should error if no initial posts are found", func() { + // randomized order to reflect the possibility of posts not coming sorted from db + posts := []*SystemIntakeGRBReviewDiscussionPost{ + post1reply1, + post1reply2, + post2reply2, + post2reply1, + } + + discussions, err := CreateGRBDiscussionsFromPosts(posts) + s.Error(err) + s.Nil(discussions) + }) +} From 9a50dd92525cf2b07c6cb577636ae2edbc1a28c0 Mon Sep 17 00:00:00 2001 From: samoddball <156127704+samoddball@users.noreply.github.com> Date: Tue, 10 Dec 2024 14:18:45 -0700 Subject: [PATCH 17/22] Easi 4645/send emails (#2910) * remove unused * implement half of tag handling * use switch as both groups are not handled the same way * CC itgov box when it is an admin who is tagged * working on mechanism to dedupe when needed * whoops - need to use poster role and info, not recipient * fix CC in test * remove DiscussionBoardType from input * clean up skip conditional * cautiously adding itgov team email...will double check things * add `UserAccountsByIDsNP` method, implement group messages * better naming * fill in reply functionality, will see if there is a way to DRY this up * use CreatedBy UUID instead of ID * nil ptr deref fix attempt * more nil ptr deref fix attempts * add emailClient * one more * trying with mockSender * add print * one more * still working on this e2e test * nil check emailClient * one more nil check * whoops * s/warn/info * whoops - use proper ID * postman * swap to fmt for spacing consistency * fix ids, update discussion links * refactor resolver logic and touchup email templates without fixing tests * update postman * clean up tests * remove unused enum * cleanup error handling * address panics first * some cleanup * remove bold tags, going one by one on cleaning up for tests * hopefully getting closer to matching emails * Update test email templates to have a valid group name * passing tests * urlFromPathAndQuery --------- Co-authored-by: Clay Benson Co-authored-by: Lee Warrick --- EASI.postman_collection.json | 52 ++- cmd/devdata/main.go | 2 + cmd/test_email_templates/main.go | 98 +++--- pkg/email/email.go | 13 + .../grb_review_discussion_group_tagged.go | 55 ++-- ...grb_review_discussion_group_tagged_test.go | 190 +++++------ ...grb_review_discussion_individual_tagged.go | 50 ++- ...eview_discussion_individual_tagged_test.go | 184 +++++------ pkg/email/grb_review_discussion_reply.go | 37 +-- pkg/email/grb_review_discussion_reply_test.go | 168 ++++------ pkg/email/templates/easi_header.gohtml | 16 +- .../grb_review_discussion_group_tagged.gohtml | 26 +- ...review_discussion_individual_tagged.gohtml | 16 +- .../grb_review_discussion_reply.gohtml | 16 +- .../system_intake_admin_upload_doc.gohtml | 3 +- .../system_intake_grb_discussions.go | 307 +++++++++++++++++- .../resolvers/system_intake_grb_reviewer.go | 2 +- pkg/models/base_struct_user.go | 2 +- pkg/models/system_intake_grb_reviewers.go | 36 ++ pkg/models/tagged_html.go | 6 + pkg/storage/system_intake_grb_reviewer.go | 8 +- pkg/storage/user_account_store.go | 12 +- 22 files changed, 802 insertions(+), 497 deletions(-) diff --git a/EASI.postman_collection.json b/EASI.postman_collection.json index 6e057d4424..1a753099bd 100644 --- a/EASI.postman_collection.json +++ b/EASI.postman_collection.json @@ -1179,7 +1179,7 @@ "mode": "graphql", "graphql": { "query": "mutation createSystemIntakeGRBDiscussion(\n $input: createSystemIntakeGRBDiscussionPostInput!\n) {\n createSystemIntakeGRBDiscussionPost(input: $input) {\n id\n content\n grbRole\n votingRole\n systemIntakeID\n createdByUserAccount {\n id\n username\n }\n }\n}\n", - "variables": "{\r\n \"input\": {\r\n \"systemIntakeID\": \"8edb237e-ad48-49b2-91cf-8534362bc6cf\",\r\n \"content\": \"

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!\\\" Visit W3Schools.com!

      \"\r\n }\r\n}" + "variables": "{\r\n \"input\": {\r\n \"systemIntakeID\": \"{{systemIntakeID}}\",\r\n \"content\": \"

      banana apple carburetor Let me look into it, ok? @Mckayla Fritsch!

      \"\r\n }\r\n}" } }, "url": { @@ -1212,7 +1212,7 @@ "mode": "graphql", "graphql": { "query": "mutation createSystemIntakeGRBDiscussion(\n $input: createSystemIntakeGRBDiscussionPostInput!\n) {\n createSystemIntakeGRBDiscussionPost(input: $input) {\n id\n content\n grbRole\n votingRole\n systemIntakeID\n createdByUserAccount {\n id\n username\n }\n }\n}\n", - "variables": "{\r\n \"input\": {\r\n \"systemIntakeID\": \"8edb237e-ad48-49b2-91cf-8534362bc6cf\",\r\n \"content\": \"

      banana apple carburetor Let me look into it, ok? @Group middle @Group2!\\\"

      \"\r\n }\r\n}" + "variables": "{\r\n \"input\": {\r\n \"systemIntakeID\": \"{{systemIntakeID}}\",\r\n \"content\": \"

      banana apple carburetor Let me look into it, ok? @Group middle @Group2!\\\"

      \"\r\n }\r\n}" } }, "url": { @@ -1245,7 +1245,40 @@ "mode": "graphql", "graphql": { "query": "mutation createSystemIntakeGRBDiscussionReply($input: createSystemIntakeGRBDiscussionReplyInput!) {\n createSystemIntakeGRBDiscussionReply(input: $input) {\n id\n content\n grbRole\n votingRole\n systemIntakeID\n createdByUserAccount {\n id\n username\n }\n }\n}", - "variables": "{\r\n \"input\": {\r\n \"initialPostID\": \"{{SystemIntakeGRBDiscussionID}}\",\r\n \"content\": \"

      monkey kiwi maduros senor

      \"\r\n }\r\n}" + "variables": "{\r\n \"input\": {\r\n \"initialPostID\": \"{{SystemIntakeGRBDiscussionID}}\",\r\n \"content\": \"

      monkey kiwi maduros senor @Mckayla Fritsch!

      \"\r\n }\r\n}" + } + }, + "url": { + "raw": "{{url}}", + "host": [ + "{{url}}" + ] + } + }, + "response": [] + }, + { + "name": "Add GRB Discussion Reply Group Tags", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "mutation createSystemIntakeGRBDiscussionReply($input: createSystemIntakeGRBDiscussionReplyInput!) {\n createSystemIntakeGRBDiscussionReply(input: $input) {\n id\n content\n grbRole\n votingRole\n systemIntakeID\n createdByUserAccount {\n id\n username\n }\n }\n}", + "variables": "{\r\n \"input\": {\r\n \"initialPostID\": \"{{SystemIntakeGRBDiscussionID}}\",\r\n \"content\": \"

      Let me look into it, ok? @Group@Group2!

      \"\r\n }\r\n}" } }, "url": { @@ -3989,9 +4022,10 @@ "listen": "test", "script": { "exec": [ - "" + "pm.collectionVariables.set(\"UserAccountID\", pm.response.json().data.userAccount.id)" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -4001,8 +4035,8 @@ "body": { "mode": "graphql", "graphql": { - "query": "query {\n userAccount(username: \"EASI_SYSTEM\") {\n id\n username\n commonName\n familyName\n givenName\n email\n locale\n }\n}", - "variables": "" + "query": "query($username: String!) {\n userAccount(username: $username) {\n id\n username\n commonName\n familyName\n givenName\n email\n locale\n }\n}", + "variables": "{\r\n \"username\": \"USR1\"\r\n}" } }, "url": { @@ -4247,6 +4281,10 @@ "key": "SystemIntakeGRBDiscussionID", "value": "", "type": "string" + }, + { + "key": "UserAccountID", + "value": "" } ] } diff --git a/cmd/devdata/main.go b/cmd/devdata/main.go index fd37b5f12c..23d68814c4 100644 --- a/cmd/devdata/main.go +++ b/cmd/devdata/main.go @@ -16,6 +16,7 @@ import ( "github.com/cms-enterprise/easi-app/cmd/devdata/mock" "github.com/cms-enterprise/easi-app/pkg/appconfig" "github.com/cms-enterprise/easi-app/pkg/appcontext" + "github.com/cms-enterprise/easi-app/pkg/dataloaders" "github.com/cms-enterprise/easi-app/pkg/local" "github.com/cms-enterprise/easi-app/pkg/models" "github.com/cms-enterprise/easi-app/pkg/storage" @@ -86,6 +87,7 @@ func main() { nonUserCtx := context.Background() nonUserCtx = mock.CtxWithNewDataloaders(nonUserCtx, store) nonUserCtx = appcontext.WithLogger(nonUserCtx, logger) + nonUserCtx = appcontext.WithUserAccountService(nonUserCtx, dataloaders.GetUserAccountByID) // userCtx is a local helper function (so we can not have to pass local variables all the time) that adds a principal // to a context object and returns it. diff --git a/cmd/test_email_templates/main.go b/cmd/test_email_templates/main.go index 9df1f7ed33..e8ba081bfd 100644 --- a/cmd/test_email_templates/main.go +++ b/cmd/test_email_templates/main.go @@ -632,16 +632,12 @@ func sendITGovEmails(ctx context.Context, client *email.Client) { err = client.SystemIntake.SendGRBReviewDiscussionReplyEmail( ctx, email.SendGRBReviewDiscussionReplyEmailInput{ - SystemIntakeID: intakeID, - UserName: "Discussion Tester #1", - RequestName: "GRB Review Discussion Test", - DiscussionBoardType: "Internal GRB Discussion Board", - GRBReviewLink: "google.com", - Role: "Voting Member", - DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, - DiscussionLink: "google.com", - ITGovernanceInboxAddress: "IT_Governance@cms.hhs.gov", - Recipient: requesterEmail, + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + Role: "Voting Member", + DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, + Recipient: requesterEmail, }, ) noErr(err) @@ -649,16 +645,12 @@ func sendITGovEmails(ctx context.Context, client *email.Client) { err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail( ctx, email.SendGRBReviewDiscussionIndividualTaggedEmailInput{ - SystemIntakeID: intakeID, - UserName: "Discussion Tester #1", - RequestName: "GRB Review Discussion Test", - DiscussionBoardType: "Internal GRB Discussion Board", - GRBReviewLink: "google.com", - Role: "Voting Member", - DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, - DiscussionLink: "google.com", - ITGovernanceInboxAddress: "IT_Governance@cms.hhs.gov", - Recipient: requesterEmail, + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + Role: "Voting Member", + DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, + Recipients: []models.EmailAddress{requesterEmail}, }, ) noErr(err) @@ -667,16 +659,12 @@ func sendITGovEmails(ctx context.Context, client *email.Client) { err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail( ctx, email.SendGRBReviewDiscussionIndividualTaggedEmailInput{ - SystemIntakeID: intakeID, - UserName: "Discussion Tester #1", - RequestName: "GRB Review Discussion Test", - DiscussionBoardType: "Internal GRB Discussion Board", - GRBReviewLink: "google.com", - Role: "", // empty to signify admin - DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, - DiscussionLink: "google.com", - ITGovernanceInboxAddress: "IT_Governance@cms.hhs.gov", - Recipient: requesterEmail, + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + Role: "Governance Admin Team", + DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, + Recipients: []models.EmailAddress{requesterEmail}, }, ) noErr(err) @@ -685,16 +673,12 @@ func sendITGovEmails(ctx context.Context, client *email.Client) { err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail( ctx, email.SendGRBReviewDiscussionIndividualTaggedEmailInput{ - SystemIntakeID: intakeID, - UserName: "Discussion Tester #1", - RequestName: "GRB Review Discussion Test", - DiscussionBoardType: "Internal GRB Discussion Board", - GRBReviewLink: "google.com", - Role: "", // empty to signify admin - DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, - DiscussionLink: "google.com", - ITGovernanceInboxAddress: "IT_Governance@cms.hhs.gov", - Recipient: requesterEmail, + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + Role: "Governance Admin Team", + DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, + Recipients: []models.EmailAddress{requesterEmail}, }, ) noErr(err) @@ -702,16 +686,13 @@ func sendITGovEmails(ctx context.Context, client *email.Client) { err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail( ctx, email.SendGRBReviewDiscussionGroupTaggedEmailInput{ - SystemIntakeID: intakeID, - UserName: "Discussion Tester #1", - RequestName: "GRB Review Discussion Test", - DiscussionBoardType: "Internal GRB Discussion Board", - GRBReviewLink: "google.com", - Role: "Voting Member", - DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, - DiscussionLink: "google.com", - ITGovernanceInboxAddress: "IT_Governance@cms.hhs.gov", - Recipients: emailNotificationRecipients.RegularRecipientEmails, + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + Role: "Voting Member, CIO", + GroupName: "Governance Admin Team", + DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, + Recipients: emailNotificationRecipients, }, ) noErr(err) @@ -720,16 +701,13 @@ func sendITGovEmails(ctx context.Context, client *email.Client) { err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail( ctx, email.SendGRBReviewDiscussionGroupTaggedEmailInput{ - SystemIntakeID: intakeID, - UserName: "Discussion Tester #1", - RequestName: "GRB Review Discussion Test", - DiscussionBoardType: "Internal GRB Discussion Board", - GRBReviewLink: "google.com", - Role: "", // empty to signify admin - DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, - DiscussionLink: "google.com", - ITGovernanceInboxAddress: "IT_Governance@cms.hhs.gov", - Recipients: emailNotificationRecipients.RegularRecipientEmails, + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + Role: "Governance Admin Team", + GroupName: "GRB", + DiscussionContent: `

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `, + Recipients: emailNotificationRecipients, }, ) noErr(err) diff --git a/pkg/email/email.go b/pkg/email/email.go index 0295711c95..878412ee21 100644 --- a/pkg/email/email.go +++ b/pkg/email/email.go @@ -448,6 +448,19 @@ func (c Client) urlFromPath(path string) string { Host: c.config.URLHost, Path: path, } + + return u.String() +} + +// urlFromPathAndQuery uses the client's URL configs to format one with a specific path and appended query +func (c Client) urlFromPathAndQuery(path string, query string) string { + u := url.URL{ + Scheme: c.config.URLScheme, + Host: c.config.URLHost, + Path: path, + RawQuery: query, + } + return u.String() } diff --git a/pkg/email/grb_review_discussion_group_tagged.go b/pkg/email/grb_review_discussion_group_tagged.go index 168d8f3338..f9412da5d1 100644 --- a/pkg/email/grb_review_discussion_group_tagged.go +++ b/pkg/email/grb_review_discussion_group_tagged.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "html/template" "path" @@ -15,17 +16,14 @@ import ( // SendGRBReviewDiscussionGroupTaggedEmailInput contains the data needed to send an email informing a group they // have been tagged in a GRB discussion type SendGRBReviewDiscussionGroupTaggedEmailInput struct { - SystemIntakeID uuid.UUID - UserName string - GroupName string // TODO NJD enum? - RequestName string - DiscussionBoardType string - GRBReviewLink string - Role string - DiscussionContent template.HTML - DiscussionLink string - ITGovernanceInboxAddress string - Recipients []models.EmailAddress + SystemIntakeID uuid.UUID + UserName string + GroupName string // TODO NJD enum? + RequestName string + Role string + DiscussionContent template.HTML + DiscussionID uuid.UUID + Recipients models.EmailNotificationRecipients } // GRBReviewDiscussionGroupTaggedBody contains the data needed for interpolation in @@ -41,7 +39,7 @@ type GRBReviewDiscussionGroupTaggedBody struct { DiscussionContent template.HTML DiscussionLink string ClientAddress string - ITGovernanceInboxAddress string + ITGovernanceInboxAddress models.EmailAddress IsAdmin bool } @@ -51,23 +49,18 @@ func (sie systemIntakeEmails) grbReviewDiscussionGroupTaggedBody(input SendGRBRe } grbReviewPath := path.Join("it-governance", input.SystemIntakeID.String(), "grb-review") - grbDiscussionPath := path.Join(grbReviewPath, "discussionID=BLAH") // TODO: NJD add actual discussion ID field - role := input.Role - if len(role) < 1 { - role = "Governance Admin Team" - } data := GRBReviewDiscussionGroupTaggedBody{ UserName: input.UserName, GroupName: input.GroupName, RequestName: input.RequestName, - DiscussionBoardType: input.DiscussionBoardType, + DiscussionBoardType: "Internal GRB Discussion Board", GRBReviewLink: sie.client.urlFromPath(grbReviewPath), - Role: role, + Role: input.Role, DiscussionContent: input.DiscussionContent, - DiscussionLink: sie.client.urlFromPath(grbDiscussionPath), - ITGovernanceInboxAddress: input.ITGovernanceInboxAddress, - IsAdmin: len(input.Role) < 1, + DiscussionLink: sie.client.urlFromPathAndQuery(grbReviewPath, fmt.Sprintf("discussionMode=reply&discussionId=%s", input.DiscussionID.String())), + ITGovernanceInboxAddress: sie.client.config.GRTEmail, + IsAdmin: input.Role == "Governance Admin Team", } var b bytes.Buffer @@ -81,22 +74,24 @@ func (sie systemIntakeEmails) grbReviewDiscussionGroupTaggedBody(input SendGRBRe // SendGRBReviewDiscussionGroupTaggedEmail sends an email to a group indicating that they have // been tagged in a GRB discussion func (sie systemIntakeEmails) SendGRBReviewDiscussionGroupTaggedEmail(ctx context.Context, input SendGRBReviewDiscussionGroupTaggedEmailInput) error { - subject := "The " + input.GroupName + "was tagged in a GRB Review discussion for " + input.RequestName + subject := fmt.Sprintf("The %[1]s was tagged in a GRB Review discussion for %[2]s", input.GroupName, input.RequestName) body, err := sie.grbReviewDiscussionGroupTaggedBody(input) if err != nil { return err } + email := NewEmail(). + // use BCC as this is going to multiple recipients + WithBCCAddresses(input.Recipients.RegularRecipientEmails). + WithSubject(subject). + WithBody(body) - allRecipients := []models.EmailAddress{} - allRecipients = append(allRecipients, models.NewEmailAddress("fake@fake.com")) + if input.Recipients.ShouldNotifyITGovernance { + email = email.WithCCAddresses([]models.EmailAddress{sie.client.config.GRTEmail}) + } return sie.client.sender.Send( ctx, - NewEmail(). - // use BCC as this is going to multiple recipients - WithBCCAddresses(allRecipients). - WithSubject(subject). - WithBody(body), + email, ) } diff --git a/pkg/email/grb_review_discussion_group_tagged_test.go b/pkg/email/grb_review_discussion_group_tagged_test.go index 5d091bab73..aeebb26d65 100644 --- a/pkg/email/grb_review_discussion_group_tagged_test.go +++ b/pkg/email/grb_review_discussion_group_tagged_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "html/template" + "path" "github.com/google/uuid" @@ -13,6 +14,7 @@ import ( func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotification() { ctx := context.Background() intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + postID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") userName := "Rock Lee" groupName := "Governance Review Board" requestName := "Salad/Sandwich Program" @@ -20,60 +22,55 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotification() role := "Consumer" discussionContent := template.HTML(`

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `) - grbReviewLink := fmt.Sprintf( - "%s://%s/it-governance/%s/grb-review", - s.config.URLScheme, - s.config.URLHost, - intakeID.String(), - ) + sender := mockSender{} + client, err := NewClient(s.config, &sender) + s.NoError(err) + + intakePath := path.Join("it-governance", intakeID.String(), "grb-review") + + grbReviewLink := client.urlFromPath(intakePath) + + discussionLink := client.urlFromPathAndQuery(intakePath, fmt.Sprintf("discussionMode=reply&discussionId=%s", postID.String())) - discussionLink := fmt.Sprintf( - "%s://%s/it-governance/%s/grb-review/discussionID=BLAH", - s.config.URLScheme, - s.config.URLHost, - intakeID.String(), - ) ITGovInboxAddress := s.config.GRTEmail.String() - sender := mockSender{} recipient := models.NewEmailAddress("fake@fake.com") - recipients := []models.EmailAddress{recipient} + recipients := models.EmailNotificationRecipients{ + RegularRecipientEmails: []models.EmailAddress{recipient}, + ShouldNotifyITGovernance: false, + ShouldNotifyITInvestment: false, + } input := SendGRBReviewDiscussionGroupTaggedEmailInput{ - SystemIntakeID: intakeID, - UserName: userName, - GroupName: groupName, - RequestName: requestName, - DiscussionBoardType: discussionBoardType, - GRBReviewLink: grbReviewLink, - Role: role, - DiscussionContent: discussionContent, - DiscussionLink: discussionLink, - ITGovernanceInboxAddress: ITGovInboxAddress, - Recipients: recipients, + SystemIntakeID: intakeID, + UserName: userName, + GroupName: groupName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipients: recipients, } - client, err := NewClient(s.config, &sender) - s.NoError(err) - err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail(ctx, input) s.NoError(err) - getExpectedEmail := func() string { - return fmt.Sprintf(` + expectedEmail := fmt.Sprintf(`

      EASi

      Easy Access to System Information

      %s tagged the %s in the %s for %s.

      View this request in EASi

      -
      +
      -

      Discussion

      +

      Discussion

      +
      +

      %s

      +

      %s

      +
      +
      %s

      -

      %s

      -

      %s

      -

      %s

      Reply in EASi @@ -81,27 +78,23 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotification()


      -

      If you have questions, please contact the Governance Team at %s.

      -

      You will continue to receive email notifications about this request until it is closed.

      `, - userName, - groupName, - discussionBoardType, - requestName, - grbReviewLink, - userName, - role, - discussionContent, - discussionLink, - ITGovInboxAddress, - ITGovInboxAddress, - ) - } + userName, + groupName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + role, + discussionContent, + discussionLink, + ITGovInboxAddress, + ITGovInboxAddress, + ) - expectedEmail := getExpectedEmail() - expectedSubject := "The " + groupName + "was tagged in a GRB Review discussion for " + requestName + expectedSubject := fmt.Sprintf("The %[1]s was tagged in a GRB Review discussion for %[2]s", groupName, requestName) s.Run("Subject is correct", func() { s.Equal(expectedSubject, sender.subject) @@ -109,7 +102,7 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotification() }) s.Run("Recipient is correct", func() { - s.ElementsMatch(sender.bccAddresses, recipients) + s.ElementsMatch(sender.bccAddresses, recipients.RegularRecipientEmails) s.Empty(sender.ccAddresses) s.Empty(sender.toAddresses) }) @@ -122,67 +115,61 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotification() func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotificationAdmin() { ctx := context.Background() intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + postID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") userName := "Rock Lee" groupName := "Governance Review Board" requestName := "Salad/Sandwich Program" discussionBoardType := "Internal GRB Discussion Board" - role := "" // empty to signify admin + role := "Governance Admin Team" discussionContent := template.HTML(`

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `) - grbReviewLink := fmt.Sprintf( - "%s://%s/it-governance/%s/grb-review", - s.config.URLScheme, - s.config.URLHost, - intakeID.String(), - ) + sender := mockSender{} + client, err := NewClient(s.config, &sender) + s.NoError(err) - discussionLink := fmt.Sprintf( - "%s://%s/it-governance/%s/grb-review/discussionID=BLAH", - s.config.URLScheme, - s.config.URLHost, - intakeID.String(), - ) - ITGovInboxAddress := s.config.GRTEmail.String() + intakePath := path.Join("it-governance", intakeID.String(), "grb-review") + + grbReviewLink := client.urlFromPath(intakePath) + + discussionLink := client.urlFromPathAndQuery(intakePath, fmt.Sprintf("discussionMode=reply&discussionId=%s", postID.String())) - sender := mockSender{} recipient := models.NewEmailAddress("fake@fake.com") - recipients := []models.EmailAddress{recipient} + recipients := models.EmailNotificationRecipients{ + RegularRecipientEmails: []models.EmailAddress{recipient}, + ShouldNotifyITGovernance: false, + ShouldNotifyITInvestment: false, + } input := SendGRBReviewDiscussionGroupTaggedEmailInput{ - SystemIntakeID: intakeID, - UserName: userName, - GroupName: groupName, - RequestName: requestName, - DiscussionBoardType: discussionBoardType, - GRBReviewLink: grbReviewLink, - Role: role, - DiscussionContent: discussionContent, - DiscussionLink: discussionLink, - ITGovernanceInboxAddress: ITGovInboxAddress, - Recipients: recipients, + SystemIntakeID: intakeID, + UserName: userName, + GroupName: groupName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipients: recipients, } - client, err := NewClient(s.config, &sender) - s.NoError(err) - err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail(ctx, input) s.NoError(err) - getExpectedEmail := func() string { - return fmt.Sprintf(` + expectedEmail := fmt.Sprintf(`

      EASi

      Easy Access to System Information

      %s tagged the %s in the %s for %s.

      View this request in EASi

      -
      +
      -

      Discussion

      +

      Discussion

      +
      +

      %s

      +

      Governance Admin Team

      +
      +
      %s

      -

      %s

      -

      Governance Admin Team

      -

      %s

      Reply in EASi @@ -190,22 +177,19 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotificationAdm


      -

      You will continue to receive email notifications about this request until it is closed.

      `, - userName, - groupName, - discussionBoardType, - requestName, - grbReviewLink, - userName, - discussionContent, - discussionLink, - ) - } + userName, + groupName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + discussionContent, + discussionLink, + ) - expectedEmail := getExpectedEmail() - expectedSubject := "The " + groupName + "was tagged in a GRB Review discussion for " + requestName + expectedSubject := fmt.Sprintf("The %[1]s was tagged in a GRB Review discussion for %[2]s", groupName, requestName) s.Run("Subject is correct", func() { s.Equal(expectedSubject, sender.subject) diff --git a/pkg/email/grb_review_discussion_individual_tagged.go b/pkg/email/grb_review_discussion_individual_tagged.go index 52f260879d..3371a5720f 100644 --- a/pkg/email/grb_review_discussion_individual_tagged.go +++ b/pkg/email/grb_review_discussion_individual_tagged.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "html/template" "path" @@ -15,16 +16,13 @@ import ( // SendGRBReviewDiscussionIndividualTaggedEmailInput contains the data needed to to send an email informing an individual they // have been tagged in a GRB discussion type SendGRBReviewDiscussionIndividualTaggedEmailInput struct { - SystemIntakeID uuid.UUID - UserName string - RequestName string - DiscussionBoardType string - GRBReviewLink string - Role string - DiscussionContent template.HTML - DiscussionLink string - ITGovernanceInboxAddress string - Recipient models.EmailAddress + SystemIntakeID uuid.UUID + UserName string + RequestName string + Role string + DiscussionID uuid.UUID + DiscussionContent template.HTML + Recipients []models.EmailAddress } // GRBReviewDiscussionIndividualTaggedBody contains the data needed for interpolation in @@ -38,7 +36,7 @@ type GRBReviewDiscussionIndividualTaggedBody struct { Role string DiscussionContent template.HTML DiscussionLink string - ITGovernanceInboxAddress string + ITGovernanceInboxAddress models.EmailAddress IsAdmin bool } @@ -48,22 +46,17 @@ func (sie systemIntakeEmails) grbReviewDiscussionIndividualTaggedBody(input Send } grbReviewPath := path.Join("it-governance", input.SystemIntakeID.String(), "grb-review") - grbDiscussionPath := path.Join(grbReviewPath, "discussionID=BLAH") // TODO: NJD add actual discussion ID field - role := input.Role - if len(role) < 1 { - role = "Governance Admin Team" - } data := GRBReviewDiscussionIndividualTaggedBody{ UserName: input.UserName, RequestName: input.RequestName, - DiscussionBoardType: input.DiscussionBoardType, + DiscussionBoardType: "Internal GRB Discussion Board", GRBReviewLink: sie.client.urlFromPath(grbReviewPath), - Role: role, + Role: input.Role, DiscussionContent: input.DiscussionContent, - DiscussionLink: sie.client.urlFromPath(grbDiscussionPath), - ITGovernanceInboxAddress: input.ITGovernanceInboxAddress, - IsAdmin: len(input.Role) < 1, + DiscussionLink: sie.client.urlFromPathAndQuery(grbReviewPath, fmt.Sprintf("discussionMode=reply&discussionId=%s", input.DiscussionID.String())), + ITGovernanceInboxAddress: sie.client.config.GRTEmail, + IsAdmin: input.Role == "Governance Admin Team", } var b bytes.Buffer @@ -77,18 +70,17 @@ func (sie systemIntakeEmails) grbReviewDiscussionIndividualTaggedBody(input Send // SendGRBReviewDiscussionIndividualTaggedEmail sends an email to an individual indicating that they have // been tagged in a GRB discussion func (sie systemIntakeEmails) SendGRBReviewDiscussionIndividualTaggedEmail(ctx context.Context, input SendGRBReviewDiscussionIndividualTaggedEmailInput) error { - subject := "You were tagged in a GRB Review discussion for " + input.RequestName + subject := fmt.Sprintf("You were tagged in a GRB Review discussion for %s", input.RequestName) body, err := sie.grbReviewDiscussionIndividualTaggedBody(input) if err != nil { return err } - return sie.client.sender.Send( - ctx, - NewEmail(). - WithToAddresses([]models.EmailAddress{input.Recipient}). - WithSubject(subject). - WithBody(body), - ) + mail := NewEmail(). + WithBCCAddresses(input.Recipients). + WithSubject(subject). + WithBody(body) + + return sie.client.sender.Send(ctx, mail) } diff --git a/pkg/email/grb_review_discussion_individual_tagged_test.go b/pkg/email/grb_review_discussion_individual_tagged_test.go index 6f02d79277..55f79ff7ec 100644 --- a/pkg/email/grb_review_discussion_individual_tagged_test.go +++ b/pkg/email/grb_review_discussion_individual_tagged_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "html/template" + "path" "github.com/google/uuid" @@ -13,64 +14,56 @@ import ( func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotification() { ctx := context.Background() intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + postID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") userName := "Rock Lee" requestName := "Salad/Sandwich Program" discussionBoardType := "Internal GRB Discussion Board" role := "Consumer" discussionContent := template.HTML(`

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `) - grbReviewLink := fmt.Sprintf( - "%s://%s/it-governance/%s/grb-review", - s.config.URLScheme, - s.config.URLHost, - intakeID.String(), - ) + sender := mockSender{} + client, err := NewClient(s.config, &sender) + s.NoError(err) + + intakePath := path.Join("it-governance", intakeID.String(), "grb-review") + + grbReviewLink := client.urlFromPath(intakePath) + + discussionLink := client.urlFromPathAndQuery(intakePath, fmt.Sprintf("discussionMode=reply&discussionId=%s", postID.String())) - discussionLink := fmt.Sprintf( - "%s://%s/it-governance/%s/grb-review/discussionID=BLAH", - s.config.URLScheme, - s.config.URLHost, - intakeID.String(), - ) ITGovInboxAddress := s.config.GRTEmail.String() - sender := mockSender{} - recipient := models.NewEmailAddress("fake@fake.com") + recipients := []models.EmailAddress{"fake@fake.com"} input := SendGRBReviewDiscussionIndividualTaggedEmailInput{ - SystemIntakeID: intakeID, - UserName: userName, - RequestName: requestName, - DiscussionBoardType: discussionBoardType, - GRBReviewLink: grbReviewLink, - Role: role, - DiscussionContent: discussionContent, - DiscussionLink: discussionLink, - ITGovernanceInboxAddress: ITGovInboxAddress, - Recipient: recipient, + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipients: recipients, } - client, err := NewClient(s.config, &sender) - s.NoError(err) - err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail(ctx, input) s.NoError(err) - getExpectedEmail := func() string { - return fmt.Sprintf(` + expectedEmail := fmt.Sprintf(`

      EASi

      Easy Access to System Information

      %s tagged you in the %s for %s.

      View this request in EASi

      -
      +
      -

      Discussion

      +

      Discussion

      +
      +

      %s

      +

      %s

      +
      +
      %s

      -

      %s

      -

      %s

      -

      %s

      Reply in EASi @@ -78,26 +71,22 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotificati


      -

      If you have questions, please contact the Governance Team at %s.

      -

      You will continue to receive email notifications about this request until it is closed.

      `, - userName, - discussionBoardType, - requestName, - grbReviewLink, - userName, - role, - discussionContent, - discussionLink, - ITGovInboxAddress, - ITGovInboxAddress, - ) - } + userName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + role, + discussionContent, + discussionLink, + ITGovInboxAddress, + ITGovInboxAddress, + ) - expectedEmail := getExpectedEmail() - expectedSubject := "You were tagged in a GRB Review discussion for " + requestName + expectedSubject := fmt.Sprintf("You were tagged in a GRB Review discussion for %s", requestName) s.Run("Subject is correct", func() { s.Equal(expectedSubject, sender.subject) @@ -105,12 +94,9 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotificati }) s.Run("Recipient is correct", func() { - allRecipients := []models.EmailAddress{ - recipient, - } - s.ElementsMatch(sender.toAddresses, allRecipients) + s.ElementsMatch(sender.bccAddresses, recipients) s.Empty(sender.ccAddresses) - s.Empty(sender.bccAddresses) + s.Empty(sender.toAddresses) }) s.Run("all info is included", func() { @@ -121,64 +107,54 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotificati func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotificationAdmin() { ctx := context.Background() intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + postID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") userName := "Rock Lee" requestName := "Salad/Sandwich Program" discussionBoardType := "Internal GRB Discussion Board" - role := "" // empty to signify admin + role := "Governance Admin Team" discussionContent := template.HTML(`

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `) - grbReviewLink := fmt.Sprintf( - "%s://%s/it-governance/%s/grb-review", - s.config.URLScheme, - s.config.URLHost, - intakeID.String(), - ) + sender := mockSender{} + client, err := NewClient(s.config, &sender) + s.NoError(err) - discussionLink := fmt.Sprintf( - "%s://%s/it-governance/%s/grb-review/discussionID=BLAH", - s.config.URLScheme, - s.config.URLHost, - intakeID.String(), - ) - ITGovInboxAddress := s.config.GRTEmail.String() + intakePath := path.Join("it-governance", intakeID.String(), "grb-review") - sender := mockSender{} - recipient := models.NewEmailAddress("fake@fake.com") + grbReviewLink := client.urlFromPath(intakePath) + + discussionLink := client.urlFromPathAndQuery(intakePath, fmt.Sprintf("discussionMode=reply&discussionId=%s", postID.String())) + + recipients := []models.EmailAddress{"fake@fake.com"} input := SendGRBReviewDiscussionIndividualTaggedEmailInput{ - SystemIntakeID: intakeID, - UserName: userName, - RequestName: requestName, - DiscussionBoardType: discussionBoardType, - GRBReviewLink: grbReviewLink, - Role: role, - DiscussionContent: discussionContent, - DiscussionLink: discussionLink, - ITGovernanceInboxAddress: ITGovInboxAddress, - Recipient: recipient, + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipients: recipients, } - client, err := NewClient(s.config, &sender) - s.NoError(err) - err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail(ctx, input) s.NoError(err) - getExpectedEmail := func() string { - return fmt.Sprintf(` + expectedEmail := fmt.Sprintf(`

      EASi

      Easy Access to System Information

      %s tagged you in the %s for %s.

      View this request in EASi

      -
      +
      -

      Discussion

      +

      Discussion

      +
      +

      %s

      +

      Governance Admin Team

      +
      +
      %s

      -

      %s

      -

      Governance Admin Team

      -

      %s

      Reply in EASi @@ -186,31 +162,27 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotificati


      -

      You will continue to receive email notifications about this request until it is closed.

      `, - userName, - discussionBoardType, - requestName, - grbReviewLink, - userName, - discussionContent, - discussionLink, - ) - } + userName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + discussionContent, + discussionLink, + ) - expectedEmail := getExpectedEmail() - expectedSubject := "You were tagged in a GRB Review discussion for " + requestName + expectedSubject := fmt.Sprintf("You were tagged in a GRB Review discussion for %s", requestName) s.Run("Subject is correct", func() { s.Equal(expectedSubject, sender.subject) - }) s.Run("Recipient is correct", func() { - s.Equal(sender.toAddresses[0], recipient) + s.Equal(sender.bccAddresses[0], recipients[0]) s.Empty(sender.ccAddresses) - s.Empty(sender.bccAddresses) + s.Empty(sender.toAddresses) }) s.Run("all info is included", func() { diff --git a/pkg/email/grb_review_discussion_reply.go b/pkg/email/grb_review_discussion_reply.go index 753dbb8cc5..b8cc2954a0 100644 --- a/pkg/email/grb_review_discussion_reply.go +++ b/pkg/email/grb_review_discussion_reply.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "html/template" "path" @@ -14,16 +15,13 @@ import ( // SendGRBReviewDiscussionReplyEmailInput contains the data needed to send the GRB discussion reply email type SendGRBReviewDiscussionReplyEmailInput struct { - SystemIntakeID uuid.UUID - UserName string - RequestName string - DiscussionBoardType string - GRBReviewLink string - Role string - DiscussionContent template.HTML - DiscussionLink string - ITGovernanceInboxAddress string - Recipient models.EmailAddress + SystemIntakeID uuid.UUID + UserName string + RequestName string + Role string + DiscussionID uuid.UUID + DiscussionContent template.HTML + Recipient models.EmailAddress } // GRBReviewDiscussionReplyBody contains the data needed for interpolation in @@ -36,7 +34,7 @@ type GRBReviewDiscussionReplyBody struct { Role string DiscussionContent template.HTML DiscussionLink string - ITGovernanceInboxAddress string + ITGovernanceInboxAddress models.EmailAddress IsAdmin bool } @@ -46,22 +44,17 @@ func (sie systemIntakeEmails) grbReviewDiscussionReplyBody(input SendGRBReviewDi } grbReviewPath := path.Join("it-governance", input.SystemIntakeID.String(), "grb-review") - grbDiscussionPath := path.Join(grbReviewPath, "discussionID=BLAH") // TODO: NJD add actual discussion ID field - role := input.Role - if len(role) < 1 { - role = "Governance Admin Team" - } data := GRBReviewDiscussionReplyBody{ UserName: input.UserName, RequestName: input.RequestName, - DiscussionBoardType: input.DiscussionBoardType, + DiscussionBoardType: "Internal GRB Discussion Board", GRBReviewLink: sie.client.urlFromPath(grbReviewPath), - Role: role, + Role: input.Role, DiscussionContent: input.DiscussionContent, - DiscussionLink: sie.client.urlFromPath(grbDiscussionPath), - ITGovernanceInboxAddress: input.ITGovernanceInboxAddress, - IsAdmin: len(input.Role) < 1, + DiscussionLink: sie.client.urlFromPathAndQuery(grbReviewPath, fmt.Sprintf("discussionMode=reply&discussionId=%s", input.DiscussionID.String())), + ITGovernanceInboxAddress: sie.client.config.GRTEmail, + IsAdmin: input.Role == "Governance Admin Team", } var b bytes.Buffer @@ -75,7 +68,7 @@ func (sie systemIntakeEmails) grbReviewDiscussionReplyBody(input SendGRBReviewDi // SendGRBReviewDiscussionReplyEmail sends an email to the EASI admin team indicating that an advice letter // has been submitted func (sie systemIntakeEmails) SendGRBReviewDiscussionReplyEmail(ctx context.Context, input SendGRBReviewDiscussionReplyEmailInput) error { - subject := "New reply to your discussion in the GRB Review for " + input.RequestName + subject := fmt.Sprintf("New reply to your discussion in the GRB Review for %s", input.RequestName) body, err := sie.grbReviewDiscussionReplyBody(input) if err != nil { diff --git a/pkg/email/grb_review_discussion_reply_test.go b/pkg/email/grb_review_discussion_reply_test.go index 879daebb12..fd08409387 100644 --- a/pkg/email/grb_review_discussion_reply_test.go +++ b/pkg/email/grb_review_discussion_reply_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "html/template" + "path" "github.com/google/uuid" @@ -13,64 +14,56 @@ import ( func (s *EmailTestSuite) TestCreateGRBReviewDiscussionReplyNotification() { ctx := context.Background() intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + postID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") userName := "Rock Lee" requestName := "Salad/Sandwich Program" discussionBoardType := "Internal GRB Discussion Board" role := "Consumer" discussionContent := template.HTML(`

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `) - grbReviewLink := fmt.Sprintf( - "%s://%s/it-governance/%s/grb-review", - s.config.URLScheme, - s.config.URLHost, - intakeID.String(), - ) + sender := mockSender{} + client, err := NewClient(s.config, &sender) + s.NoError(err) + + intakePath := path.Join("it-governance", intakeID.String(), "grb-review") + + grbReviewLink := client.urlFromPath(intakePath) + + discussionLink := client.urlFromPathAndQuery(intakePath, fmt.Sprintf("discussionMode=reply&discussionId=%s", postID.String())) - discussionLink := fmt.Sprintf( - "%s://%s/it-governance/%s/grb-review/discussionID=BLAH", - s.config.URLScheme, - s.config.URLHost, - intakeID.String(), - ) ITGovInboxAddress := s.config.GRTEmail.String() - sender := mockSender{} recipient := models.NewEmailAddress("fake@fake.com") input := SendGRBReviewDiscussionReplyEmailInput{ - SystemIntakeID: intakeID, - UserName: userName, - RequestName: requestName, - DiscussionBoardType: discussionBoardType, - GRBReviewLink: grbReviewLink, - Role: role, - DiscussionContent: discussionContent, - DiscussionLink: discussionLink, - ITGovernanceInboxAddress: ITGovInboxAddress, - Recipient: recipient, + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipient: recipient, } - client, err := NewClient(s.config, &sender) - s.NoError(err) - err = client.SystemIntake.SendGRBReviewDiscussionReplyEmail(ctx, input) s.NoError(err) - getExpectedEmail := func() string { - return fmt.Sprintf(` + expectedEmail := fmt.Sprintf(`

      EASi

      Easy Access to System Information

      %s replied to your discussion on the %s for %s.

      View this request in EASi

      -
      +
      -

      Discussion

      +

      Discussion Reply

      +
      +

      %s

      +

      %s

      +
      +
      %s

      -

      %s

      -

      %s

      -

      %s

      Reply in EASi @@ -78,26 +71,22 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionReplyNotification() {


      -

      If you have questions, please contact the Governance Team at %s.

      -

      You will continue to receive email notifications about this request until it is closed.

      `, - userName, - discussionBoardType, - requestName, - grbReviewLink, - userName, - role, - discussionContent, - discussionLink, - ITGovInboxAddress, - ITGovInboxAddress, - ) - } + userName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + role, + discussionContent, + discussionLink, + ITGovInboxAddress, + ITGovInboxAddress, + ) - expectedEmail := getExpectedEmail() - expectedSubject := "New reply to your discussion in the GRB Review for " + requestName + expectedSubject := fmt.Sprintf("New reply to your discussion in the GRB Review for %s", requestName) s.Run("Subject is correct", func() { s.Equal(expectedSubject, sender.subject) @@ -121,64 +110,54 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionReplyNotification() { func (s *EmailTestSuite) TestCreateGRBReviewDiscussionReplyNotificationAdmin() { ctx := context.Background() intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + postID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") userName := "Rock Lee" requestName := "Salad/Sandwich Program" discussionBoardType := "Internal GRB Discussion Board" - role := "" // empty to signify admin + role := "Governance Admin Team" discussionContent := template.HTML(`

      banana apple carburetor Let me look into it, ok? @Audrey Abrams!"

      `) - grbReviewLink := fmt.Sprintf( - "%s://%s/it-governance/%s/grb-review", - s.config.URLScheme, - s.config.URLHost, - intakeID.String(), - ) + sender := mockSender{} + client, err := NewClient(s.config, &sender) + s.NoError(err) - discussionLink := fmt.Sprintf( - "%s://%s/it-governance/%s/grb-review/discussionID=BLAH", - s.config.URLScheme, - s.config.URLHost, - intakeID.String(), - ) - ITGovInboxAddress := s.config.GRTEmail.String() + intakePath := path.Join("it-governance", intakeID.String(), "grb-review") + + grbReviewLink := client.urlFromPath(intakePath) + + discussionLink := client.urlFromPathAndQuery(intakePath, fmt.Sprintf("discussionMode=reply&discussionId=%s", postID.String())) - sender := mockSender{} recipient := models.NewEmailAddress("fake@fake.com") input := SendGRBReviewDiscussionReplyEmailInput{ - SystemIntakeID: intakeID, - UserName: userName, - RequestName: requestName, - DiscussionBoardType: discussionBoardType, - GRBReviewLink: grbReviewLink, - Role: role, - DiscussionContent: discussionContent, - DiscussionLink: discussionLink, - ITGovernanceInboxAddress: ITGovInboxAddress, - Recipient: recipient, + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipient: recipient, } - client, err := NewClient(s.config, &sender) - s.NoError(err) - err = client.SystemIntake.SendGRBReviewDiscussionReplyEmail(ctx, input) s.NoError(err) - getExpectedEmail := func() string { - return fmt.Sprintf(` + expectedEmail := fmt.Sprintf(`

      EASi

      Easy Access to System Information

      %s replied to your discussion on the %s for %s.

      View this request in EASi

      -
      +
      -

      Discussion

      +

      Discussion Reply

      +
      +

      %s

      +

      Governance Admin Team

      +
      +
      %s

      -

      %s

      -

      Governance Admin Team

      -

      %s

      Reply in EASi @@ -186,21 +165,18 @@ func (s *EmailTestSuite) TestCreateGRBReviewDiscussionReplyNotificationAdmin() {


      -

      You will continue to receive email notifications about this request until it is closed.

      `, - userName, - discussionBoardType, - requestName, - grbReviewLink, - userName, - discussionContent, - discussionLink, - ) - } + userName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + discussionContent, + discussionLink, + ) - expectedEmail := getExpectedEmail() - expectedSubject := "New reply to your discussion in the GRB Review for " + requestName + expectedSubject := fmt.Sprintf("New reply to your discussion in the GRB Review for %s", requestName) s.Run("Subject is correct", func() { s.Equal(expectedSubject, sender.subject) diff --git a/pkg/email/templates/easi_header.gohtml b/pkg/email/templates/easi_header.gohtml index 3656d6adbe..417a182855 100644 --- a/pkg/email/templates/easi_header.gohtml +++ b/pkg/email/templates/easi_header.gohtml @@ -25,9 +25,6 @@ .no-margin-top { margin-top: 0; } - .no-margin-bottom { - margin-bottom: 0; - } hr { border-style: solid; border-color: #bbb; @@ -37,6 +34,19 @@ .mention { color: #005EA2; } + + .quote>p:first-of-type { + margin-top: 0; + } + .quote>p:first-of-type::before { + content: '\201C'; + } + .quote>p:last-of-type { + margin-bottom: 0; + } + .quote>p:last-of-type::after { + content: '\201D'; + } diff --git a/pkg/email/templates/grb_review_discussion_group_tagged.gohtml b/pkg/email/templates/grb_review_discussion_group_tagged.gohtml index 13054bc3a7..503fe135b5 100644 --- a/pkg/email/templates/grb_review_discussion_group_tagged.gohtml +++ b/pkg/email/templates/grb_review_discussion_group_tagged.gohtml @@ -3,24 +3,28 @@

      {{.UserName}} tagged the {{.GroupName}} in the {{.DiscussionBoardType}} for {{.RequestName}}.

      View this request in EASi

      + +
      + +

      Discussion

      +
      +

      {{.UserName}}

      +

      {{.Role}}

      + +
      +
      {{.DiscussionContent}}
      -

      Discussion


      -

      {{.UserName}}

      -

      {{.Role}}

      -

      {{.DiscussionContent}}

      - - Reply in EASi - + + Reply in EASi +


      {{if not .IsAdmin}} -
      -

      If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.

      +

      If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.

      {{end}} -

      You will continue to receive email notifications about this request until it is closed.

      diff --git a/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml b/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml index 15260db93c..05e484b077 100644 --- a/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml +++ b/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml @@ -3,13 +3,19 @@

      {{.UserName}} tagged you in the {{.DiscussionBoardType}} for {{.RequestName}}.

      View this request in EASi

      + +
      + +

      Discussion

      +
      +

      {{.UserName}}

      +

      {{.Role}}

      + +
      +
      {{.DiscussionContent}}
      -

      Discussion


      -

      {{.UserName}}

      -

      {{.Role}}

      -

      {{.DiscussionContent}}

      Reply in EASi @@ -18,9 +24,7 @@


      {{if not .IsAdmin}} -

      If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.

      {{end}} -

      You will continue to receive email notifications about this request until it is closed.

      diff --git a/pkg/email/templates/grb_review_discussion_reply.gohtml b/pkg/email/templates/grb_review_discussion_reply.gohtml index 69ee7f64bd..3dd18c1384 100644 --- a/pkg/email/templates/grb_review_discussion_reply.gohtml +++ b/pkg/email/templates/grb_review_discussion_reply.gohtml @@ -3,13 +3,19 @@

      {{.UserName}} replied to your discussion on the {{.DiscussionBoardType}} for {{.RequestName}}.

      View this request in EASi

      + +
      + +

      Discussion Reply

      +
      +

      {{.UserName}}

      +

      {{.Role}}

      + +
      +
      {{.DiscussionContent}}
      -

      Discussion


      -

      {{.UserName}}

      -

      {{.Role}}

      -

      {{.DiscussionContent}}

      Reply in EASi @@ -18,9 +24,7 @@


      {{if not .IsAdmin}} -

      If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.

      {{end}} -

      You will continue to receive email notifications about this request until it is closed.

      diff --git a/pkg/email/templates/system_intake_admin_upload_doc.gohtml b/pkg/email/templates/system_intake_admin_upload_doc.gohtml index ce6ea27a8e..bd089f5948 100644 --- a/pkg/email/templates/system_intake_admin_upload_doc.gohtml +++ b/pkg/email/templates/system_intake_admin_upload_doc.gohtml @@ -10,8 +10,7 @@

      Request Summary

      Project title: {{.RequestName}}

      -

      Requester: {{.RequesterName}}, {{.RequestComponent}} -

      +

      Requester: {{.RequesterName}}, {{.RequestComponent}}

      diff --git a/pkg/graph/resolvers/system_intake_grb_discussions.go b/pkg/graph/resolvers/system_intake_grb_discussions.go index a9aa4f1444..f1e46235a8 100644 --- a/pkg/graph/resolvers/system_intake_grb_discussions.go +++ b/pkg/graph/resolvers/system_intake_grb_discussions.go @@ -3,8 +3,13 @@ package resolvers import ( "context" "errors" + "fmt" + "html/template" + "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/samber/lo" + "go.uber.org/zap" "github.com/cms-enterprise/easi-app/pkg/appcontext" "github.com/cms-enterprise/easi-app/pkg/email" @@ -22,24 +27,66 @@ func CreateSystemIntakeGRBDiscussionPost( input models.CreateSystemIntakeGRBDiscussionPostInput, ) (*models.SystemIntakeGRBReviewDiscussionPost, error) { return sqlutils.WithTransactionRet(ctx, store, func(tx *sqlx.Tx) (*models.SystemIntakeGRBReviewDiscussionPost, error) { - principal := appcontext.Principal(ctx).Account().ID + principal := appcontext.Principal(ctx) + intakeID := input.SystemIntakeID - principalGRBReviewer, err := GetPrincipalGRBReviewerBySystemIntakeID(ctx, intakeID) + + // fetch system intake for email logic + systemIntake, err := store.FetchSystemIntakeByIDNP(ctx, tx, intakeID) + if err != nil { + return nil, err + } + + principalAsGRBReviewer, err := GetPrincipalAsGRBReviewerBySystemIntakeID(ctx, intakeID) if err != nil { return nil, err } + isAdmin := services.AuthorizeRequireGRTJobCode(ctx) - if principalGRBReviewer == nil && !isAdmin { + if principalAsGRBReviewer == nil && !isAdmin { return nil, errors.New("user not authorized to create discussion post") } - post := models.NewSystemIntakeGRBReviewDiscussionPost(principal) + + post := models.NewSystemIntakeGRBReviewDiscussionPost(principal.Account().ID) post.Content = input.Content.RawContent post.SystemIntakeID = intakeID - if principalGRBReviewer != nil { - post.VotingRole = &principalGRBReviewer.GRBVotingRole - post.GRBRole = &principalGRBReviewer.GRBReviewerRole + if principalAsGRBReviewer != nil { + post.VotingRole = &principalAsGRBReviewer.GRBVotingRole + post.GRBRole = &principalAsGRBReviewer.GRBReviewerRole + } + + // save in DB + result, err := store.CreateSystemIntakeGRBDiscussionPost(ctx, tx, post) + if err != nil { + return nil, err + } + + if emailClient == nil { + return result, nil + } + + authorRole, err := getAuthorRoleFromPost(post) + if err != nil { + return nil, err + } + + err = sendDiscussionEmailsForTags( + ctx, + store, + emailClient, + tx, + intakeID, + systemIntake.ProjectName.String, + post.ID, + authorRole, + input.Content.UniqueTags(), + input.Content.ToTemplate(), + ) + if err != nil { + return nil, err } - return store.CreateSystemIntakeGRBDiscussionPost(ctx, tx, post) + + return result, nil }) } @@ -55,19 +102,25 @@ func CreateSystemIntakeGRBDiscussionReply( if err != nil { return nil, err } + intakeID := initialPost.SystemIntakeID if initialPost.ReplyToID != nil { return nil, errors.New("only top level posts can be replied to") } - principalGRBReviewer, err := GetPrincipalGRBReviewerBySystemIntakeID(ctx, intakeID) + + principalGRBReviewer, err := GetPrincipalAsGRBReviewerBySystemIntakeID(ctx, intakeID) if err != nil { return nil, err } + isAdmin := services.AuthorizeRequireGRTJobCode(ctx) if principalGRBReviewer == nil && !isAdmin { return nil, errors.New("user not authorized to create discussion post") } - post := models.NewSystemIntakeGRBReviewDiscussionPost(appcontext.Principal(ctx).Account().ID) + + // get user who made this reply post + replyPoster := appcontext.Principal(ctx).Account() + post := models.NewSystemIntakeGRBReviewDiscussionPost(replyPoster.ID) post.Content = input.Content.RawContent post.SystemIntakeID = intakeID post.ReplyToID = &initialPost.ID @@ -75,6 +128,238 @@ func CreateSystemIntakeGRBDiscussionReply( post.VotingRole = &principalGRBReviewer.GRBVotingRole post.GRBRole = &principalGRBReviewer.GRBReviewerRole } - return store.CreateSystemIntakeGRBDiscussionPost(ctx, tx, post) + + result, err := store.CreateSystemIntakeGRBDiscussionPost(ctx, tx, post) + if err != nil { + return nil, err + } + + systemIntake, err := store.FetchSystemIntakeByIDNP(ctx, tx, intakeID) + if err != nil { + return nil, err + } + + if systemIntake == nil { + return nil, errors.New("problem finding system intake when handling GRB reply") + } + + // the initial poster will receive a notification + // in the event the initial poster is also tagged in a reply, we do not send both emails + // we only send the "someone replied" email + initialPoster, err := store.UserAccountGetByID(ctx, tx, initialPost.CreatedBy) + if err != nil { + return nil, err + } + + if initialPoster == nil { + return nil, errors.New("problem finding initial poster when handling GRB reply") + } + + // if no email client, do not proceed + if emailClient == nil { + return result, nil + } + + // so first, we can send the reply email + authorRole, err := getAuthorRoleFromPost(post) + if err != nil { + return nil, err + } + if err := emailClient.SystemIntake.SendGRBReviewDiscussionReplyEmail(ctx, email.SendGRBReviewDiscussionReplyEmailInput{ + SystemIntakeID: intakeID, + UserName: replyPoster.CommonName, + RequestName: systemIntake.ProjectName.String, + DiscussionID: initialPost.ID, + Role: authorRole, + DiscussionContent: input.Content.ToTemplate(), + Recipient: models.EmailAddress(initialPoster.Email), + }); err != nil { + return nil, err + } + + uniqueTags := input.Content.UniqueTags() + // strip discussion author from tags in case author was tagged + uniqueTagsWithoutInitialPoster := lo.Filter(uniqueTags, func(t *models.Tag, _ int) bool { + return t.TaggedContentID != initialPoster.ID + }) + + // then handle emails for tags in the post + err = sendDiscussionEmailsForTags( + ctx, + store, + emailClient, + tx, + intakeID, + systemIntake.ProjectName.String, + initialPost.ID, + authorRole, + uniqueTagsWithoutInitialPoster, + input.Content.ToTemplate(), + ) + if err != nil { + return nil, err + } + + return result, nil }) } + +// handles sending emails for various tags in a discussion post +func sendDiscussionEmailsForTags( + ctx context.Context, + store *storage.Store, + emailClient *email.Client, + tx *sqlx.Tx, + intakeID uuid.UUID, + intakeRequestName string, + discussionID uuid.UUID, + postAuthorRole string, + uniqueTags []*models.Tag, + content template.HTML, +) error { + // if no tags, we can return here + if len(uniqueTags) < 1 { + return nil + } + + logger := appcontext.ZLogger(ctx) + principal := appcontext.Principal(ctx) + + grbReviewers, err := store.SystemIntakeGRBReviewersBySystemIntakeIDsNP(ctx, tx, []uuid.UUID{intakeID}) + if err != nil { + return err + } + + grbReviewerCache := map[uuid.UUID]*models.SystemIntakeGRBReviewer{} + // map for ease + for _, grbReviewer := range grbReviewers { + // not sure if possible, but just in case + if grbReviewer == nil { + continue + } + + grbReviewerCache[grbReviewer.UserID] = grbReviewer + } + + // check if the grb group is being emailed, in which case we should make sure we do not send any individual emails out + var grbGroupFound bool + + groupTagTypes := []models.TagType{} + individualTagAcctIDs := []uuid.UUID{} + + // split individual and group tags + for _, tag := range uniqueTags { + if tag == nil { + continue + } + if tag.TagType == models.TagTypeUserAccount { + if _, ok := grbReviewerCache[tag.TaggedContentID]; !ok { + // this means someone was tagged who should not have been + logger.Info("tagged user is not a grb reviewer for this intake", zap.String("systemIntakeID", intakeID.String())) + continue + } + individualTagAcctIDs = append(individualTagAcctIDs, tag.TaggedContentID) + } else { + if tag.TagType == models.TagTypeGroupGrbReviewers { + grbGroupFound = true + } + groupTagTypes = append(groupTagTypes, tag.TagType) + } + } + + // handle group tags + if len(groupTagTypes) > 0 { + // send email for each tag group + for _, groupTagType := range groupTagTypes { + recipients := models.EmailNotificationRecipients{} + var groupName string + + switch groupTagType { + + case models.TagTypeGroupItGov: + recipients.ShouldNotifyITGovernance = true + groupName = "Governance Admin Team" + + case models.TagTypeGroupGrbReviewers: + groupName = "GRB" + reviewerIDs := lo.Keys(grbReviewerCache) + grbAccts, err := store.UserAccountsByIDsNP(ctx, tx, reviewerIDs) + if err != nil { + logger.Error("problem getting recipients by id when sending out tag email notifications", zap.Error(err)) + return err + } + for _, acct := range grbAccts { + recipients.RegularRecipientEmails = append(recipients.RegularRecipientEmails, models.EmailAddress(acct.Email)) + } + + // should never happen, but skip these cases + case models.TagTypeUserAccount: + continue + default: + continue + } + + if err := emailClient.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail(ctx, email.SendGRBReviewDiscussionGroupTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: principal.Account().CommonName, + GroupName: groupName, + RequestName: intakeRequestName, + Role: postAuthorRole, + DiscussionID: discussionID, + DiscussionContent: content, + Recipients: recipients, + }); err != nil { + return err + } + } + } + + // handle and get email addresses for individual tags + if !grbGroupFound && len(individualTagAcctIDs) > 0 { + // for individual tags, we need to build an email based on the passed in UUID + recipientAccts, err := store.UserAccountsByIDsNP(ctx, tx, individualTagAcctIDs) + if err != nil { + logger.Error("problem getting recipients by id when sending out tag email notifications", zap.Error(err)) + return err + } + recipients := []models.EmailAddress{} + for _, recipientAcct := range recipientAccts { + recipients = append(recipients, models.EmailAddress(recipientAcct.Email)) + } + + if err := emailClient.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail(ctx, email.SendGRBReviewDiscussionIndividualTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: principal.Account().CommonName, + RequestName: intakeRequestName, + Role: postAuthorRole, + DiscussionID: discussionID, + DiscussionContent: content, + Recipients: recipients, + }); err != nil { + return err + } + } + return nil +} + +func getAuthorRoleFromPost(post *models.SystemIntakeGRBReviewDiscussionPost) (string, error) { + if post.VotingRole == nil || post.GRBRole == nil { + return "Governance Admin Team", nil + } + + if len(*post.VotingRole) < 1 || len(*post.GRBRole) < 1 { + return "Governance Admin Team", nil + } + + votingRoleStr, err := post.VotingRole.Humanize() + if err != nil { + return "", err + } + + grbRoleStr, err := post.GRBRole.Humanize() + if err != nil { + return "", err + } + + return fmt.Sprintf("%[1]s member, %[2]s", votingRoleStr, grbRoleStr), nil +} diff --git a/pkg/graph/resolvers/system_intake_grb_reviewer.go b/pkg/graph/resolvers/system_intake_grb_reviewer.go index e3175d878b..620d2facbc 100644 --- a/pkg/graph/resolvers/system_intake_grb_reviewer.go +++ b/pkg/graph/resolvers/system_intake_grb_reviewer.go @@ -236,7 +236,7 @@ func StartGRBReview( }) } -func GetPrincipalGRBReviewerBySystemIntakeID(ctx context.Context, systemIntakeID uuid.UUID) (*models.SystemIntakeGRBReviewer, error) { +func GetPrincipalAsGRBReviewerBySystemIntakeID(ctx context.Context, systemIntakeID uuid.UUID) (*models.SystemIntakeGRBReviewer, error) { principalUserAcctID := appcontext.Principal(ctx).Account().ID grbReviewers, err := dataloaders.GetSystemIntakeGRBReviewersBySystemIntakeID(ctx, systemIntakeID) if err != nil { diff --git a/pkg/models/base_struct_user.go b/pkg/models/base_struct_user.go index 1c129140e6..1dc32c1a4c 100644 --- a/pkg/models/base_struct_user.go +++ b/pkg/models/base_struct_user.go @@ -8,7 +8,7 @@ import ( //TODO: This will replace base struct when the user table is fully implemented -// BaseStructUser represents the shared data in common betwen all models +// BaseStructUser represents the shared data in common between all models type BaseStructUser struct { ID uuid.UUID `json:"id" db:"id"` createdByRelation diff --git a/pkg/models/system_intake_grb_reviewers.go b/pkg/models/system_intake_grb_reviewers.go index 5c5cd01f8f..573916f7e0 100644 --- a/pkg/models/system_intake_grb_reviewers.go +++ b/pkg/models/system_intake_grb_reviewers.go @@ -1,6 +1,7 @@ package models import ( + "fmt" "time" "github.com/google/uuid" @@ -33,6 +34,41 @@ const ( SIGRBRVRAlternate SIGRBReviewerVotingRole = "ALTERNATE" ) +func (r SIGRBReviewerVotingRole) Humanize() (string, error) { + var grbVotingRoleTranslationsMap = map[SIGRBReviewerVotingRole]string{ + SIGRBRVRVoting: "Voting", + SIGRBRVRNonVoting: "Non-voting", + SIGRBRVRAlternate: "Alternate", + } + translation, ok := grbVotingRoleTranslationsMap[r] + if !ok { + return "", fmt.Errorf("%s is not a valid SIGRBReviewerVotingRole", r) + } + return translation, nil +} + +func (r SIGRBReviewerRole) Humanize() (string, error) { + var grbRoleTranslationsMap = map[SIGRBReviewerRole]string{ + SIGRBRRCoChairCIO: "Co-Chair - CIO", + SIGRBRRCoChairCFO: "CO-Chair - CFO", + SIGRBRRCoChairHCA: "CO-Chair - HCA", + SIGRBRRACA3021Rep: "ACA 3021 Rep", + SIGRBRRCCIIORep: "CCIIO Rep", + SIGRBRRProgOpBDGChair: "Program Operations BDG Chair", + SIGRBRRCMCSRep: "CMCS Rep", + SIGRBRRFedAdminBDGChair: "Fed Admin BDG Chair", + SIGRBRRProgIntBDGChair: "Program Integrity BDG Chair", + SIGRBRRQIORep: "QIO Rep", + SIGRBRRSubjectMatterExpert: "Subject Matter Expert (SME)", + SIGRBRROther: "Other", + } + translation, ok := grbRoleTranslationsMap[r] + if !ok { + return "", fmt.Errorf("%s is not a valid SIGRBReviewerRole", r) + } + return translation, nil +} + // SystemIntakeGRBReviewer describes type SystemIntakeGRBReviewer struct { BaseStructUser diff --git a/pkg/models/tagged_html.go b/pkg/models/tagged_html.go index 3fabb4185a..eef678da48 100644 --- a/pkg/models/tagged_html.go +++ b/pkg/models/tagged_html.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "html/template" "io" "regexp" @@ -180,3 +181,8 @@ func (th *TaggedHTML) Scan(src interface{}) error { func (th TaggedHTML) Value() (driver.Value, error) { return string(th.RawContent), nil } + +func (th TaggedHTML) ToTemplate() template.HTML { + sanitized := sanitization.SanitizeTaggedHTML(th.RawContent) + return template.HTML(sanitized) //nolint +} diff --git a/pkg/storage/system_intake_grb_reviewer.go b/pkg/storage/system_intake_grb_reviewer.go index 5f3d25b5f7..bf8460557f 100644 --- a/pkg/storage/system_intake_grb_reviewer.go +++ b/pkg/storage/system_intake_grb_reviewer.go @@ -71,8 +71,14 @@ func (s *Store) DeleteSystemIntakeGRBReviewer(ctx context.Context, tx *sqlx.Tx, } func (s *Store) SystemIntakeGRBReviewersBySystemIntakeIDs(ctx context.Context, systemIntakeIDs []uuid.UUID) ([]*models.SystemIntakeGRBReviewer, error) { + return sqlutils.WithTransactionRet[[]*models.SystemIntakeGRBReviewer](ctx, s, func(tx *sqlx.Tx) ([]*models.SystemIntakeGRBReviewer, error) { + return s.SystemIntakeGRBReviewersBySystemIntakeIDsNP(ctx, tx, systemIntakeIDs) + }) +} + +func (s *Store) SystemIntakeGRBReviewersBySystemIntakeIDsNP(ctx context.Context, np sqlutils.NamedPreparer, systemIntakeIDs []uuid.UUID) ([]*models.SystemIntakeGRBReviewer, error) { var systemIntakeGRBReviewers []*models.SystemIntakeGRBReviewer - return systemIntakeGRBReviewers, namedSelect(ctx, s.db, &systemIntakeGRBReviewers, sqlqueries.SystemIntakeGRBReviewer.GetBySystemIntakeID, args{ + return systemIntakeGRBReviewers, namedSelect(ctx, np, &systemIntakeGRBReviewers, sqlqueries.SystemIntakeGRBReviewer.GetBySystemIntakeID, args{ "system_intake_ids": pq.Array(systemIntakeIDs), }) } diff --git a/pkg/storage/user_account_store.go b/pkg/storage/user_account_store.go index 0fd1188491..a5a454b0a7 100644 --- a/pkg/storage/user_account_store.go +++ b/pkg/storage/user_account_store.go @@ -4,8 +4,10 @@ import ( "context" "database/sql" _ "embed" + "errors" "github.com/google/uuid" + "github.com/jmoiron/sqlx" "github.com/lib/pq" "github.com/cms-enterprise/easi-app/pkg/authentication" @@ -80,7 +82,7 @@ func (s *Store) UserAccountGetByID(ctx context.Context, np sqlutils.NamedPrepare }) if err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, err @@ -91,8 +93,14 @@ func (s *Store) UserAccountGetByID(ctx context.Context, np sqlutils.NamedPrepare // UserAccountsByIDs gets user accounts by user ID func (s *Store) UserAccountsByIDs(ctx context.Context, userIDs []uuid.UUID) ([]*authentication.UserAccount, error) { + return sqlutils.WithTransactionRet[[]*authentication.UserAccount](ctx, s, func(tx *sqlx.Tx) ([]*authentication.UserAccount, error) { + return s.UserAccountsByIDsNP(ctx, tx, userIDs) + }) +} + +func (s *Store) UserAccountsByIDsNP(ctx context.Context, np sqlutils.NamedPreparer, userIDs []uuid.UUID) ([]*authentication.UserAccount, error) { var accounts []*authentication.UserAccount - return accounts, namedSelect(ctx, s.db, &accounts, sqlqueries.UserAccount.GetByIDs, args{ + return accounts, namedSelect(ctx, np, &accounts, sqlqueries.UserAccount.GetByIDs, args{ "user_ids": pq.Array(userIDs), }) } From ce5a0be9e483f17be84ef195a20843e09ccebeaf Mon Sep 17 00:00:00 2001 From: Ashley Terstriep <60187543+aterstriep@users.noreply.github.com> Date: Wed, 11 Dec 2024 17:51:54 -0600 Subject: [PATCH 18/22] [EASI-4658] Integrate discussions tagging (#2917) * Fix ID bug Removed duplicate ID on container in previous PR. Caused a bug where mention suggestions box does not render correctly. Replacing duplicate ID prop for now. * Rename reviewers query * Replace mock mention suggestions with grbReviewers * Fix duplicate ID on `EditorContent` Use editor element instead of ID to append suggestions popup * Typed MentionTextArea component files * Add `data-label` attr for rendering mentions * Snapshot * Fix no results translation and padding * Display most recent discussion * Update recent activity to consider replies --- pkg/sanitization/tagged_html.go | 2 +- .../MentionTextArea/MentionList.tsx | 164 ++++++++++-------- .../__snapshots__/index.test.tsx.snap | 2 + src/components/MentionTextArea/index.tsx | 74 ++++---- src/components/MentionTextArea/suggestion.ts | 89 ++++++---- src/components/MentionTextArea/util.tsx | 82 ++++++++- ...view.ts => GetSystemIntakeGRBReviewers.ts} | 2 +- src/gql/gen/graphql.ts | 38 ++-- src/i18n/en-US/general.ts | 1 + src/types/discussions.ts | 38 ++++ src/views/DiscussionBoard/Discussion.test.tsx | 2 + src/views/DiscussionBoard/Discussion.tsx | 7 +- src/views/DiscussionBoard/StartDiscussion.tsx | 7 +- .../components/DiscussionForm.tsx | 5 +- src/views/DiscussionBoard/index.tsx | 37 +++- .../GRBReview/Discussions.test.tsx | 6 +- .../GRBReview/Discussions.tsx | 40 +++-- .../GRBReviewerForm/AddReviewerFromEua.tsx | 4 +- .../GRBReview/GRBReviewerForm/index.test.tsx | 22 +-- .../GRBReview/GRBReviewerForm/index.tsx | 4 +- .../GovernanceReviewTeam/GRBReview/index.tsx | 7 +- src/views/GovernanceReviewTeam/index.tsx | 4 +- 22 files changed, 406 insertions(+), 231 deletions(-) rename src/gql/apolloGQL/grbReview/{GetSystemIntakeGRBReview.ts => GetSystemIntakeGRBReviewers.ts} (86%) diff --git a/pkg/sanitization/tagged_html.go b/pkg/sanitization/tagged_html.go index b9a884fd82..2673af6e93 100644 --- a/pkg/sanitization/tagged_html.go +++ b/pkg/sanitization/tagged_html.go @@ -31,6 +31,6 @@ func createTaggedHTMLPolicy() *bluemonday.Policy { policy := bluemonday.NewPolicy() // rules for tags policy.AllowElements("span", "p") - policy.AllowAttrs("data-type", "class", "tag-type", "data-id-db").OnElements("span") + policy.AllowAttrs("data-type", "class", "tag-type", "data-id-db", "data-label").OnElements("span") return policy } diff --git a/src/components/MentionTextArea/MentionList.tsx b/src/components/MentionTextArea/MentionList.tsx index 4570d4b57b..4fde7ad184 100644 --- a/src/components/MentionTextArea/MentionList.tsx +++ b/src/components/MentionTextArea/MentionList.tsx @@ -8,8 +8,13 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { TagType } from 'gql/gen/graphql'; import Spinner from 'components/Spinner'; +import { + MentionListOnKeyDown, + MentionSuggestionProps +} from 'types/discussions'; import './index.scss'; @@ -21,91 +26,98 @@ export const SuggestionLoading = () => { ); }; -// Handler dropdown scroll event on keypress +/** Handler dropdown scroll event on keypress */ const scrollIntoView = () => { const selectedElm = document.querySelector('.is-selected'); selectedElm?.scrollIntoView({ block: 'nearest' }); }; -const MentionList = forwardRef((props: any, ref) => { - const { t } = useTranslation('discussionsMisc'); +/** Renders the list of suggestions within `MentionTextArea` */ +const MentionList = forwardRef( + (props, ref) => { + const { t } = useTranslation('general'); - const [selectedIndex, setSelectedIndex] = useState(0); + const [selectedIndex, setSelectedIndex] = useState(0); - // Sets the selected mention within the editor props - const selectItem = (index: any) => { - const item = props.items[index]; + /** Sets the selected mention within the editor props */ + const selectItem = (index: number) => { + const item = props.items[index]; - if (item) { - props.command({ - id: item.username, - label: item.displayName, - 'tag-type': item.tagType - }); - } - }; - - const upHandler = () => { - setSelectedIndex( - (selectedIndex + props.items?.length - 1) % props.items?.length - ); - scrollIntoView(); - }; - - const downHandler = () => { - setSelectedIndex((selectedIndex + 1) % props.items?.length); - scrollIntoView(); - }; - - const enterHandler = () => { - selectItem(selectedIndex); - }; - - useEffect(() => setSelectedIndex(0), [props.items]); - - useImperativeHandle(ref, () => ({ - onKeyDown: ({ event }: { event: any }) => { - if (event.key === 'ArrowUp' || (event.shiftKey && event.key === 'Tab')) { - upHandler(); - return true; + if (item) { + props.command({ + 'tag-type': item.tagType, + label: item.displayName, + 'data-label': item.displayName, + 'data-id-db': item.tagType === TagType.USER_ACCOUNT ? item.id : '' + }); } - - if ( - event.key === 'ArrowDown' || - (!event.shiftKey && event.key === 'Tab') - ) { - downHandler(); - return true; + }; + + const upHandler = () => { + setSelectedIndex( + (selectedIndex + props.items?.length - 1) % props.items?.length + ); + scrollIntoView(); + }; + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items?.length); + scrollIntoView(); + }; + + const enterHandler = () => { + selectItem(selectedIndex); + }; + + useEffect(() => setSelectedIndex(0), [props.items]); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + if ( + event.key === 'ArrowUp' || + (event.shiftKey && event.key === 'Tab') + ) { + upHandler(); + return true; + } + + if ( + event.key === 'ArrowDown' || + (!event.shiftKey && event.key === 'Tab') + ) { + downHandler(); + return true; + } + + if (event.key === 'Enter') { + enterHandler(); + return true; + } + + return false; } - - if (event.key === 'Enter') { - enterHandler(); - return true; - } - - return false; - } - })); - - return ( -
      - {props.items?.length ? ( - props.items?.map((item: any, index: any) => ( - - )) - ) : ( -
      {t('noResults')}
      - )} -
      - ); -}); + })); + + return ( +
      + {props.items?.length ? ( + props.items?.map((item, index) => ( + + )) + ) : ( + {t('noResults')} + )} +
      + ); + } +); export default MentionList; diff --git a/src/components/MentionTextArea/__snapshots__/index.test.tsx.snap b/src/components/MentionTextArea/__snapshots__/index.test.tsx.snap index 81ff351e47..92b1570e5f 100644 --- a/src/components/MentionTextArea/__snapshots__/index.test.tsx.snap +++ b/src/components/MentionTextArea/__snapshots__/index.test.tsx.snap @@ -4,6 +4,7 @@ exports[`MentionTextArea component > renders the component to view text 1`] = `
      renders the editable text area component 1`
      -Attrs of selected mention are accessed through node prop */ -const MentionComponent = ({ node }: { node: any }) => { +/** The rendered Mention after selected from MentionList */ +// This component can be any react jsx component, but must be wrapped in +const MentionComponent = ({ node }: NodeViewProps) => { + // Get attributes of selected mention const { label } = node.attrs; - // Label may return null if the text was truncated by - // In this case don't render the mention, and shift the line up by the height of the non-rendered label - if (!label) { - return
      ; - } + if (!label) return null; return ( @@ -40,9 +40,14 @@ const MentionComponent = ({ node }: { node: any }) => { ); }; -/* Extended TipTap Mention class with additional attributes -Additionally sets a addNodeView to render custo JSX as mention */ -const CustomMention = Mention.extend({ +/** + * Extended TipTap Mention class with additional attributes + * + * Additionally sets a addNodeView to render custo JSX as mention + */ +const CustomMention: Node< + MentionOptions +> = Mention.extend({ atom: true, selectable: true, addAttributes() { @@ -64,6 +69,7 @@ const CustomMention = Mention.extend({ type MentionTextAreaProps = { id: string; setFieldValue?: (value: string) => void; + mentionSuggestions?: MentionSuggestion[]; editable?: boolean; disabled?: boolean; initialContent?: string; @@ -80,6 +86,7 @@ const MentionTextArea = React.forwardRef( { id, setFieldValue, + mentionSuggestions, editable = false, disabled, initialContent, @@ -96,35 +103,13 @@ const MentionTextArea = React.forwardRef( const [tagAlert, setTagAlert] = useState(false); /** Mock users array for testing until tagging functionality is implemented */ - const fetchUsers = ({ query }: { query: string }) => { - return [ - { username: 'a', displayName: 'Admin lead', tagType: 'other' }, - { - username: 'b', - displayName: 'Governance Admin Team', - tagType: 'other' - }, - { - username: 'c', - displayName: 'Governance Review Board (GRB)', - tagType: 'other' - }, - { - username: 'OSYC', - displayName: 'Grant Eliezer', - tagType: 'user' - }, - { - username: 'MKCK', - displayName: 'Forest Brown', - tagType: 'user' - }, - { - username: 'PJEA', - displayName: 'Janae Stokes', - tagType: 'user' - } - ]; + const fetchUsers = ({ query }: { query: string }): MentionSuggestion[] => { + if (!mentionSuggestions) return []; + + return mentionSuggestions.filter(val => + // Convert both strings to lowercase so filter is not case-sensitive + val.displayName.toLowerCase().includes(query.toLowerCase()) + ); }; /** Character limit when truncating text in non-editable text area */ @@ -188,7 +173,9 @@ const MentionTextArea = React.forwardRef( } }, // Sets an alert if a mention is selected, and users/teams will be emailed - onSelectionUpdate: ({ editor: input }: any) => { + onSelectionUpdate: ({ + editor: input + }: EditorEvents['selectionUpdate']) => { setTagAlert(!!getMentions(input?.getJSON()).length); }, content @@ -212,6 +199,7 @@ const MentionTextArea = React.forwardRef( return ( <> { - const editorID = props.editor.options.editorProps.attributes.id; - const elem = document.getElementById(editorID); - const rect = elem?.getBoundingClientRect(); - const mentionRect = props.clientRect(); +const getClientRect = ({ + editor, + clientRect +}: MentionSuggestionProps): GetReferenceClientRect => { + const { element } = editor.options; + const rect = element.getBoundingClientRect(); + const mentionRect = clientRect?.(); return () => new DOMRect( rect?.left, - mentionRect.y, - mentionRect.width, - mentionRect.height + mentionRect?.y, + mentionRect?.width, + mentionRect?.height ); }; -const suggestion = { +const suggestion: Omit< + SuggestionOptions, + 'editor' +> = { allowSpaces: true, render: () => { - let reactRenderer: any; - let spinner: any; - let popup: any; + let reactRenderer: ReactRenderer< + MentionListOnKeyDown, + MentionSuggestionProps + >; + + let spinner: Partial; + let popup: Partial; return { // If we had async initial data - load a spinning symbol until onStart gets called // We have hardcoded in memory data for current implementation, doesn't currently get called - onBeforeStart: (props: any) => { - const editorID = props.editor.options.editorProps.attributes.id; - + onBeforeStart: props => { if (!props.clientRect) { return; } @@ -42,9 +57,9 @@ const suggestion = { editor: props.editor }); - spinner = tippy('body', { + [spinner] = tippy('body', { getReferenceClientRect: getClientRect(props), - appendTo: () => document.getElementById(editorID) || document.body, + appendTo: props.editor.options.element, content: reactRenderer.element, showOnCreate: true, interactive: false, @@ -54,23 +69,21 @@ const suggestion = { }, // Render any available suggestions when mention trigger is first called - @ - onStart: (props: any) => { - const editorID = props.editor.options.editorProps.attributes.id; - + onStart: props => { if (!props.clientRect) { return; } - spinner[0].hide(); + spinner.hide?.(); reactRenderer = new ReactRenderer(MentionList, { props, editor: props.editor }); - popup = tippy('body', { + [popup] = tippy('body', { getReferenceClientRect: getClientRect(props), - appendTo: () => document.getElementById(editorID) || document.body, + appendTo: props.editor.options.element, content: reactRenderer.element, showOnCreate: true, interactive: true, @@ -80,46 +93,46 @@ const suggestion = { }, // When async data/suggestions return, hide the spinner and show the updated list - onUpdate(props: any) { + onUpdate: props => { reactRenderer.updateProps(props); if (!props.clientRect) { return; } - popup[0].setProps({ + popup.setProps?.({ getReferenceClientRect: getClientRect(props) }); - spinner[0].setProps({ + + spinner.setProps?.({ getReferenceClientRect: getClientRect(props) }); - spinner[0].hide(); + spinner.hide?.(); - popup[0].show(); + popup.show?.(); }, // If a valid character key, render the spinner until onUpdate gets called to rerender updated list - onKeyDown(props: any) { + onKeyDown: props => { if (props.event.key === 'Escape') { - popup[0].hide(); - spinner[0].hide(); + popup.hide?.(); + spinner.hide?.(); return true; } if (props.event.key.length === 1 || props.event.key === 'Backspace') { - popup[0].hide(); - - spinner[0].show(); + popup.hide?.(); + spinner.show?.(); } - return reactRenderer.ref?.onKeyDown(props); + return !!reactRenderer?.ref && reactRenderer.ref.onKeyDown(props); }, onExit() { - popup[0].destroy(); - spinner[0].destroy(); + popup.destroy?.(); + spinner.destroy?.(); reactRenderer.destroy(); } }; diff --git a/src/components/MentionTextArea/util.tsx b/src/components/MentionTextArea/util.tsx index 3fb38d603f..e3cf2973ab 100644 --- a/src/components/MentionTextArea/util.tsx +++ b/src/components/MentionTextArea/util.tsx @@ -1,15 +1,79 @@ -// Possible Util to extract only mentions from content -// eslint-disable-next-line import/prefer-default-export -export const getMentions = (data: any) => { - const mentions: any = []; - - data?.content?.forEach((para: any) => { - para?.content?.forEach((content: any) => { - if (content?.type === 'mention') { - mentions.push(content?.attrs); +import { JSONContent } from '@tiptap/core'; + +/** + * Returns array of mentions from Tiptap input JSON data + * + * @example getMentions(input?.getJSON()) + */ +export const getMentions = < + /** Optional type param for mention attributes if return array needs to be typed */ + MentionAttrsType extends Record = Record +>( + data: JSONContent +): MentionAttrsType[] => { + const mentions: MentionAttrsType[] = []; + + data?.content?.forEach(paragraph => { + paragraph?.content?.forEach(content => { + if (content?.attrs && content?.type === 'mention') { + mentions.push(content.attrs as MentionAttrsType); } }); }); return mentions; }; + +/** Generic discussion type with only `createdAt` props */ +interface DiscussionTimestamps { + initialPost: { + createdAt: string; + }; + replies: { createdAt: string }[]; +} + +/** Compare initialPost with replies and find the most recent `createdAt` value */ +const getMostRecentTimestamp = ({ + initialPost, + replies +}: DiscussionType) => { + if (replies.length === 0) return initialPost.createdAt; + + return replies.reduce( + (latest, current) => { + return current.createdAt > latest.createdAt ? current : latest; + }, + // Start with the initialPost + initialPost + // Return the `createdAt` value + ).createdAt; +}; + +/** + * Find and return the discussion object with the most recent activity + * + * Returns undefined if discussions array is empty + */ +export const getMostRecentDiscussion = < + DiscussionType extends DiscussionTimestamps +>( + discussions: DiscussionType[] +): DiscussionType | undefined => { + if (discussions.length === 0) return undefined; + + return discussions.reduce((mostRecentDiscussion, currentDiscussion) => { + /** Latest createdAt value for current discussion */ + const currentDiscussionCreatedAt = + getMostRecentTimestamp(currentDiscussion); + + // Latest createdAt value for most recent discussion + const mostRecentDiscussionCreatedAt = + getMostRecentTimestamp(mostRecentDiscussion); + + return currentDiscussionCreatedAt > mostRecentDiscussionCreatedAt + ? currentDiscussion + : mostRecentDiscussion; + }); +}; + +export default getMentions; diff --git a/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReview.ts b/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReviewers.ts similarity index 86% rename from src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReview.ts rename to src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReviewers.ts index f17ea45400..9f0a9461f8 100644 --- a/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReview.ts +++ b/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReviewers.ts @@ -4,7 +4,7 @@ import SystemIntakeGRBReviewer from './SystemIntakeGRBReviewer'; export default gql(/* GraphQL */ ` ${SystemIntakeGRBReviewer} - query GetSystemIntakeGRBReview($id: UUID!) { + query GetSystemIntakeGRBReviewers($id: UUID!) { systemIntake(id: $id) { id grbReviewStartedAt diff --git a/src/gql/gen/graphql.ts b/src/gql/gen/graphql.ts index 1cf80816b9..da22b128bb 100644 --- a/src/gql/gen/graphql.ts +++ b/src/gql/gen/graphql.ts @@ -3278,12 +3278,12 @@ export type GetSystemIntakeGRBDiscussionsQueryVariables = Exact<{ export type GetSystemIntakeGRBDiscussionsQuery = { __typename: 'Query', systemIntake?: { __typename: 'SystemIntake', id: UUID, grbDiscussions: Array<{ __typename: 'SystemIntakeGRBReviewDiscussion', initialPost: { __typename: 'SystemIntakeGRBReviewDiscussionPost', id: UUID, content: HTML, votingRole?: SystemIntakeGRBReviewerVotingRole | null, grbRole?: SystemIntakeGRBReviewerRole | null, systemIntakeID: UUID, createdAt: Time, createdByUserAccount: { __typename: 'UserAccount', id: UUID, commonName: string } }, replies: Array<{ __typename: 'SystemIntakeGRBReviewDiscussionPost', id: UUID, content: HTML, votingRole?: SystemIntakeGRBReviewerVotingRole | null, grbRole?: SystemIntakeGRBReviewerRole | null, systemIntakeID: UUID, createdAt: Time, createdByUserAccount: { __typename: 'UserAccount', id: UUID, commonName: string } }> }> } | null }; -export type GetSystemIntakeGRBReviewQueryVariables = Exact<{ +export type GetSystemIntakeGRBReviewersQueryVariables = Exact<{ id: Scalars['UUID']['input']; }>; -export type GetSystemIntakeGRBReviewQuery = { __typename: 'Query', systemIntake?: { __typename: 'SystemIntake', id: UUID, grbReviewStartedAt?: Time | null, grbReviewers: Array<{ __typename: 'SystemIntakeGRBReviewer', id: UUID, grbRole: SystemIntakeGRBReviewerRole, votingRole: SystemIntakeGRBReviewerVotingRole, userAccount: { __typename: 'UserAccount', id: UUID, username: string, commonName: string, email: string } }> } | null }; +export type GetSystemIntakeGRBReviewersQuery = { __typename: 'Query', systemIntake?: { __typename: 'SystemIntake', id: UUID, grbReviewStartedAt?: Time | null, grbReviewers: Array<{ __typename: 'SystemIntakeGRBReviewer', id: UUID, grbRole: SystemIntakeGRBReviewerRole, votingRole: SystemIntakeGRBReviewerVotingRole, userAccount: { __typename: 'UserAccount', id: UUID, username: string, commonName: string, email: string } }> } | null }; export type SystemIntakeWithReviewRequestedFragment = { __typename: 'SystemIntake', id: UUID, requestName?: string | null, requesterName?: string | null, requesterComponent?: string | null, grbDate?: Time | null }; @@ -3853,8 +3853,8 @@ export type GetSystemIntakeGRBDiscussionsQueryHookResult = ReturnType; export type GetSystemIntakeGRBDiscussionsSuspenseQueryHookResult = ReturnType; export type GetSystemIntakeGRBDiscussionsQueryResult = Apollo.QueryResult; -export const GetSystemIntakeGRBReviewDocument = gql` - query GetSystemIntakeGRBReview($id: UUID!) { +export const GetSystemIntakeGRBReviewersDocument = gql` + query GetSystemIntakeGRBReviewers($id: UUID!) { systemIntake(id: $id) { id grbReviewStartedAt @@ -3866,37 +3866,37 @@ export const GetSystemIntakeGRBReviewDocument = gql` ${SystemIntakeGRBReviewerFragmentDoc}`; /** - * __useGetSystemIntakeGRBReviewQuery__ + * __useGetSystemIntakeGRBReviewersQuery__ * - * To run a query within a React component, call `useGetSystemIntakeGRBReviewQuery` and pass it any options that fit your needs. - * When your component renders, `useGetSystemIntakeGRBReviewQuery` returns an object from Apollo Client that contains loading, error, and data properties + * To run a query within a React component, call `useGetSystemIntakeGRBReviewersQuery` and pass it any options that fit your needs. + * When your component renders, `useGetSystemIntakeGRBReviewersQuery` returns an object from Apollo Client that contains loading, error, and data properties * you can use to render your UI. * * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; * * @example - * const { data, loading, error } = useGetSystemIntakeGRBReviewQuery({ + * const { data, loading, error } = useGetSystemIntakeGRBReviewersQuery({ * variables: { * id: // value for 'id' * }, * }); */ -export function useGetSystemIntakeGRBReviewQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: GetSystemIntakeGRBReviewQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { +export function useGetSystemIntakeGRBReviewersQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: GetSystemIntakeGRBReviewersQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(GetSystemIntakeGRBReviewDocument, options); + return Apollo.useQuery(GetSystemIntakeGRBReviewersDocument, options); } -export function useGetSystemIntakeGRBReviewLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { +export function useGetSystemIntakeGRBReviewersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(GetSystemIntakeGRBReviewDocument, options); + return Apollo.useLazyQuery(GetSystemIntakeGRBReviewersDocument, options); } -export function useGetSystemIntakeGRBReviewSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) { +export function useGetSystemIntakeGRBReviewersSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) { const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} - return Apollo.useSuspenseQuery(GetSystemIntakeGRBReviewDocument, options); + return Apollo.useSuspenseQuery(GetSystemIntakeGRBReviewersDocument, options); } -export type GetSystemIntakeGRBReviewQueryHookResult = ReturnType; -export type GetSystemIntakeGRBReviewLazyQueryHookResult = ReturnType; -export type GetSystemIntakeGRBReviewSuspenseQueryHookResult = ReturnType; -export type GetSystemIntakeGRBReviewQueryResult = Apollo.QueryResult; +export type GetSystemIntakeGRBReviewersQueryHookResult = ReturnType; +export type GetSystemIntakeGRBReviewersLazyQueryHookResult = ReturnType; +export type GetSystemIntakeGRBReviewersSuspenseQueryHookResult = ReturnType; +export type GetSystemIntakeGRBReviewersQueryResult = Apollo.QueryResult; export const GetSystemIntakesWithReviewRequestedDocument = gql` query GetSystemIntakesWithReviewRequested { systemIntakesWithReviewRequested { @@ -5034,7 +5034,7 @@ export const TypedCreateSystemIntakeGRBReviewersDocument = {"kind":"Document","d export const TypedDeleteSystemIntakeGRBReviewerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteSystemIntakeGRBReviewer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteSystemIntakeGRBReviewerInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteSystemIntakeGRBReviewer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const TypedgetGRBReviewersComparisonsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getGRBReviewersComparisons"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"compareGRBReviewersByIntakeID"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requestName"}},{"kind":"Field","name":{"kind":"Name","value":"reviewers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"userAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isCurrentReviewer"}}]}}]}}]}}]} as unknown as DocumentNode; export const TypedGetSystemIntakeGRBDiscussionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSystemIntakeGRBDiscussions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"systemIntake"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbDiscussions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussion"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussionPost"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussionPost"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"createdByUserAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"systemIntakeID"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussion"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussion"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"initialPost"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussionPost"}}]}},{"kind":"Field","name":{"kind":"Name","value":"replies"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewDiscussionPost"}}]}}]}}]} as unknown as DocumentNode; -export const TypedGetSystemIntakeGRBReviewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSystemIntakeGRBReview"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"systemIntake"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbReviewStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"grbReviewers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"userAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; +export const TypedGetSystemIntakeGRBReviewersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSystemIntakeGRBReviewers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"systemIntake"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbReviewStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"grbReviewers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"userAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; export const TypedGetSystemIntakesWithReviewRequestedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSystemIntakesWithReviewRequested"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"systemIntakesWithReviewRequested"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeWithReviewRequested"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeWithReviewRequested"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntake"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requestName"}},{"kind":"Field","name":{"kind":"Name","value":"requesterName"}},{"kind":"Field","name":{"kind":"Name","value":"requesterComponent"}},{"kind":"Field","name":{"kind":"Name","value":"grbDate"}}]}}]} as unknown as DocumentNode; export const TypedStartGRBReviewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StartGRBReview"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"StartGRBReviewInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startGRBReview"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const TypedUpdateSystemIntakeGRBReviewerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSystemIntakeGRBReviewer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateSystemIntakeGRBReviewerInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSystemIntakeGRBReviewer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SystemIntakeGRBReviewer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"grbRole"}},{"kind":"Field","name":{"kind":"Name","value":"votingRole"}},{"kind":"Field","name":{"kind":"Name","value":"userAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"commonName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; diff --git a/src/i18n/en-US/general.ts b/src/i18n/en-US/general.ts index aa24b0f750..ca365b9012 100644 --- a/src/i18n/en-US/general.ts +++ b/src/i18n/en-US/general.ts @@ -11,6 +11,7 @@ const general = { remove: 'Remove', pageLoading: 'Loading the page', loadingResults: 'Loading results', + noResults: 'No results', noInfoToDisplay: 'No information to display', noDataAvailable: 'No data available', readMore: 'Read more', diff --git a/src/types/discussions.ts b/src/types/discussions.ts index abbd44010c..0e939d5304 100644 --- a/src/types/discussions.ts +++ b/src/types/discussions.ts @@ -1,5 +1,43 @@ +import { SuggestionProps } from '@tiptap/suggestion'; +import { TagType } from 'gql/gen/graphql'; + import { AlertProps } from 'components/shared/Alert'; +/** Error and success alerts for the discussion form */ export type DiscussionAlert = | (Omit & { message: string }) | null; + +export type MentionSuggestion = + | { + tagType: TagType.GROUP_IT_GOV | TagType.GROUP_GRB_REVIEWERS; + displayName: string; + } + | { + tagType: TagType.USER_ACCOUNT; + displayName: string; + id: string; + }; + +/** HTML attributes used for rendering mentions */ +export type MentionAttributes = { + /** Text displayed within mention `span` tag */ + label: string; + /** Label attribute for rendering mentions */ + 'data-label': string; + /** UUID for `USER_ACCOUNT` tag types */ + 'data-id-db': string; + 'tag-type': TagType; +}; + +/** Suggestion props for use within Tiptap configurations */ +export type MentionSuggestionProps = SuggestionProps< + MentionSuggestion, + MentionAttributes +>; + +/** `MentionList` component forwarded ref attributes */ +export type MentionListOnKeyDown = { + /** onKeyDown handler for rendering the suggestions popup and loading spinner */ + onKeyDown: ({ event }: { event: KeyboardEvent }) => boolean; +}; diff --git a/src/views/DiscussionBoard/Discussion.test.tsx b/src/views/DiscussionBoard/Discussion.test.tsx index 5441a22c03..3659e6ff71 100644 --- a/src/views/DiscussionBoard/Discussion.test.tsx +++ b/src/views/DiscussionBoard/Discussion.test.tsx @@ -26,6 +26,7 @@ describe('Discussion component', () => { { void; setDiscussionAlert: (discussionAlert: DiscussionAlert) => void; + mentionSuggestions: MentionSuggestion[]; }; /** @@ -25,7 +26,8 @@ type DiscussionProps = { const Discussion = ({ discussion, closeModal, - setDiscussionAlert + setDiscussionAlert, + mentionSuggestions }: DiscussionProps) => { const { t } = useTranslation('discussions'); const [showReplies, setShowReplies] = useState(true); @@ -78,6 +80,7 @@ const Discussion = ({ closeModal={closeModal} initialPostID={initialPost.id} setDiscussionAlert={setDiscussionAlert} + mentionSuggestions={mentionSuggestions} />
      ); diff --git a/src/views/DiscussionBoard/StartDiscussion.tsx b/src/views/DiscussionBoard/StartDiscussion.tsx index 9ae408c591..50fffeb212 100644 --- a/src/views/DiscussionBoard/StartDiscussion.tsx +++ b/src/views/DiscussionBoard/StartDiscussion.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { DiscussionAlert } from 'types/discussions'; +import { DiscussionAlert, MentionSuggestion } from 'types/discussions'; import DiscussionForm from './components/DiscussionForm'; @@ -9,6 +9,7 @@ type StartDiscussionProps = { systemIntakeID: string; closeModal: () => void; setDiscussionAlert: (discussionAlert: DiscussionAlert) => void; + mentionSuggestions: MentionSuggestion[]; }; /** @@ -17,7 +18,8 @@ type StartDiscussionProps = { const StartDiscussion = ({ systemIntakeID, closeModal, - setDiscussionAlert + setDiscussionAlert, + mentionSuggestions }: StartDiscussionProps) => { const { t } = useTranslation('discussions'); @@ -35,6 +37,7 @@ const StartDiscussion = ({ type="discussion" systemIntakeID={systemIntakeID} setDiscussionAlert={setDiscussionAlert} + mentionSuggestions={mentionSuggestions} />
      ); diff --git a/src/views/DiscussionBoard/components/DiscussionForm.tsx b/src/views/DiscussionBoard/components/DiscussionForm.tsx index 239f87b40b..2627727998 100644 --- a/src/views/DiscussionBoard/components/DiscussionForm.tsx +++ b/src/views/DiscussionBoard/components/DiscussionForm.tsx @@ -17,7 +17,7 @@ import HelpText from 'components/shared/HelpText'; import Label from 'components/shared/Label'; import RequiredAsterisk from 'components/shared/RequiredAsterisk'; import useDiscussionParams from 'hooks/useDiscussionParams'; -import { DiscussionAlert } from 'types/discussions'; +import { DiscussionAlert, MentionSuggestion } from 'types/discussions'; import discussionSchema from 'validations/discussionSchema'; type DiscussionContent = { @@ -27,6 +27,7 @@ type DiscussionContent = { interface DiscussionFormProps { setDiscussionAlert: (discussionAlert: DiscussionAlert) => void; closeModal: () => void; + mentionSuggestions: MentionSuggestion[]; } interface DiscussionProps extends DiscussionFormProps { @@ -47,6 +48,7 @@ const DiscussionForm = ({ type, closeModal, setDiscussionAlert, + mentionSuggestions, ...mutationProps }: DiscussionProps | ReplyProps) => { const { t } = useTranslation('discussions'); @@ -166,6 +168,7 @@ const DiscussionForm = ({ className="height-auto" initialContent={field.value} setFieldValue={field.onChange} + mentionSuggestions={mentionSuggestions} /> )} /> diff --git a/src/views/DiscussionBoard/index.tsx b/src/views/DiscussionBoard/index.tsx index 3ce140cd44..68916efcc5 100644 --- a/src/views/DiscussionBoard/index.tsx +++ b/src/views/DiscussionBoard/index.tsx @@ -1,10 +1,14 @@ import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; -import { SystemIntakeGRBReviewDiscussionFragment } from 'gql/gen/graphql'; +import { + SystemIntakeGRBReviewDiscussionFragment, + SystemIntakeGRBReviewerFragment, + TagType +} from 'gql/gen/graphql'; import Alert from 'components/shared/Alert'; import useDiscussionParams, { DiscussionMode } from 'hooks/useDiscussionParams'; -import { DiscussionAlert } from 'types/discussions'; +import { DiscussionAlert, MentionSuggestion } from 'types/discussions'; import Discussion from './Discussion'; import DiscussionModalWrapper from './DiscussionModalWrapper'; @@ -15,11 +19,13 @@ import './index.scss'; type DiscussionBoardProps = { systemIntakeID: string; + grbReviewers: SystemIntakeGRBReviewerFragment[]; grbDiscussions: SystemIntakeGRBReviewDiscussionFragment[]; }; function DiscussionBoard({ systemIntakeID, + grbReviewers, grbDiscussions }: DiscussionBoardProps) { /** Discussion alert state for form success and error messages */ @@ -28,13 +34,32 @@ function DiscussionBoard({ const { getDiscussionParams, pushDiscussionQuery } = useDiscussionParams(); const { discussionMode, discussionId } = getDiscussionParams(); - const activeDiscussion = - grbDiscussions.find(d => d.initialPost.id === discussionId) || null; - // Reset discussionAlert when the side panel changes from certain modes const [lastMode, setLastMode] = useState( discussionMode ); + + /** Mention suggestions for discussion form tags */ + const mentionSuggestions: MentionSuggestion[] = [ + { + displayName: 'Governance Admin Team', + tagType: TagType.GROUP_IT_GOV + }, + { + displayName: 'Governance Review Board (GRB)', + tagType: TagType.GROUP_GRB_REVIEWERS + }, + ...grbReviewers.map(({ userAccount }) => ({ + key: userAccount.username, + tagType: TagType.USER_ACCOUNT, + displayName: userAccount.commonName, + id: userAccount.id + })) + ]; + + const activeDiscussion = + grbDiscussions.find(d => d.initialPost.id === discussionId) || null; + useEffect(() => { if (lastMode !== discussionMode) { if (lastMode === 'view' || lastMode === 'reply') { @@ -70,6 +95,7 @@ function DiscussionBoard({ {discussionMode === 'start' && ( { - + ); @@ -77,7 +77,7 @@ describe('Discussions', () => { - + ); @@ -97,7 +97,7 @@ describe('Discussions', () => { render( - + ); diff --git a/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx b/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx index e58121991a..c985c078b2 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx @@ -2,31 +2,43 @@ import React from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Button, Icon } from '@trussworks/react-uswds'; import classNames from 'classnames'; -import { useGetSystemIntakeGRBDiscussionsQuery } from 'gql/gen/graphql'; +import { + SystemIntakeGRBReviewDiscussionFragment, + SystemIntakeGRBReviewerFragment, + useGetSystemIntakeGRBDiscussionsQuery +} from 'gql/gen/graphql'; +import { getMostRecentDiscussion } from 'components/MentionTextArea/util'; import Alert from 'components/shared/Alert'; import CollapsableLink from 'components/shared/CollapsableLink'; import IconButton from 'components/shared/IconButton'; +import Spinner from 'components/Spinner'; import useDiscussionParams from 'hooks/useDiscussionParams'; import DiscussionBoard from 'views/DiscussionBoard'; import DiscussionPost from 'views/DiscussionBoard/components/DiscussionPost'; type DiscussionsProps = { systemIntakeID: string; + grbReviewers: SystemIntakeGRBReviewerFragment[]; className?: string; }; /** Displays recent discussions on GRB Review tab */ -const Discussions = ({ systemIntakeID, className }: DiscussionsProps) => { +const Discussions = ({ + systemIntakeID, + grbReviewers, + className +}: DiscussionsProps) => { const { t } = useTranslation('discussions'); const { pushDiscussionQuery } = useDiscussionParams(); - const { data } = useGetSystemIntakeGRBDiscussionsQuery({ + const { data, loading } = useGetSystemIntakeGRBDiscussionsQuery({ variables: { id: systemIntakeID } }); - const grbDiscussions = data?.systemIntake?.grbDiscussions; + const grbDiscussions: SystemIntakeGRBReviewDiscussionFragment[] | undefined = + data?.systemIntake?.grbDiscussions; if (!grbDiscussions) return null; @@ -34,13 +46,14 @@ const Discussions = ({ systemIntakeID, className }: DiscussionsProps) => { discussion => discussion.replies.length === 0 ).length; - const recentDiscussion = - grbDiscussions.length > 0 ? grbDiscussions[0] : undefined; + /** Discussion with latest activity - either when discussion was created or latest reply */ + const recentDiscussion = getMostRecentDiscussion(grbDiscussions); return ( <> @@ -134,11 +147,16 @@ const Discussions = ({ systemIntakeID, className }: DiscussionsProps) => {

      {t('general.mostRecentActivity')}

      - + + {loading ? ( + + ) : ( + + )} ) : ( // If no discussions, show alert diff --git a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/AddReviewerFromEua.tsx b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/AddReviewerFromEua.tsx index f2fa86fa1d..85993a6d58 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/AddReviewerFromEua.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/AddReviewerFromEua.tsx @@ -5,7 +5,7 @@ import { ErrorMessage } from '@hookform/error-message'; import { yupResolver } from '@hookform/resolvers/yup'; import { Button, Dropdown, Form, FormGroup } from '@trussworks/react-uswds'; import { - GetSystemIntakeGRBReviewDocument, + GetSystemIntakeGRBReviewersDocument, SystemIntakeGRBReviewerFragment, useUpdateSystemIntakeGRBReviewerMutation } from 'gql/gen/graphql'; @@ -51,7 +51,7 @@ const AddReviewerFromEua = ({ const [updateGRBReviewer] = useUpdateSystemIntakeGRBReviewerMutation({ refetchQueries: [ { - query: GetSystemIntakeGRBReviewDocument, + query: GetSystemIntakeGRBReviewersDocument, variables: { id: systemId } } ] diff --git a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx index 67c11b7bae..7f913b9170 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx @@ -9,9 +9,9 @@ import { GetGRBReviewersComparisonsDocument, GetGRBReviewersComparisonsQuery, GetGRBReviewersComparisonsQueryVariables, - GetSystemIntakeGRBReviewDocument, - GetSystemIntakeGRBReviewQuery, - GetSystemIntakeGRBReviewQueryVariables, + GetSystemIntakeGRBReviewersDocument, + GetSystemIntakeGRBReviewersQuery, + GetSystemIntakeGRBReviewersQueryVariables, SystemIntakeGRBReviewerFragment, SystemIntakeGRBReviewerRole, SystemIntakeGRBReviewerVotingRole, @@ -133,14 +133,14 @@ const updateSystemIntakeGRBReviewerQuery: MockedQuery< } }; -const getSystemIntakeGRBReviewQuery = ( +const getSystemIntakeGRBReviewersQuery = ( reviewer?: SystemIntakeGRBReviewerFragment ): MockedQuery< - GetSystemIntakeGRBReviewQuery, - GetSystemIntakeGRBReviewQueryVariables + GetSystemIntakeGRBReviewersQuery, + GetSystemIntakeGRBReviewersQueryVariables > => ({ request: { - query: GetSystemIntakeGRBReviewDocument, + query: GetSystemIntakeGRBReviewersDocument, variables: { id: systemIntake.id } @@ -191,8 +191,8 @@ describe('GRB reviewer form', () => { cedarContactsQuery('Je'), cedarContactsQuery('Jerry Seinfeld'), createSystemIntakeGRBReviewersQuery, - getSystemIntakeGRBReviewQuery(), - getSystemIntakeGRBReviewQuery(grbReviewer) + getSystemIntakeGRBReviewersQuery(), + getSystemIntakeGRBReviewersQuery(grbReviewer) ]} > @@ -279,8 +279,8 @@ describe('GRB reviewer form', () => { getGRBReviewersComparisonsQuery, cedarContactsQuery(contactLabel), updateSystemIntakeGRBReviewerQuery, - getSystemIntakeGRBReviewQuery(grbReviewer), - getSystemIntakeGRBReviewQuery(updatedGRBReviewer) + getSystemIntakeGRBReviewersQuery(grbReviewer), + getSystemIntakeGRBReviewersQuery(updatedGRBReviewer) ]} > diff --git a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.tsx b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.tsx index dff06c2757..eee6a2063e 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.tsx @@ -3,7 +3,7 @@ import { Trans, useTranslation } from 'react-i18next'; import { useHistory, useParams } from 'react-router-dom'; import { Grid, Icon } from '@trussworks/react-uswds'; import { - GetSystemIntakeGRBReviewDocument, + GetSystemIntakeGRBReviewersDocument, SystemIntakeGRBReviewerFragment, useCreateSystemIntakeGRBReviewersMutation } from 'gql/gen/graphql'; @@ -41,7 +41,7 @@ const GRBReviewerForm = ({ }>(); const [mutate] = useCreateSystemIntakeGRBReviewersMutation({ - refetchQueries: [GetSystemIntakeGRBReviewDocument] + refetchQueries: [GetSystemIntakeGRBReviewersDocument] }); const createGRBReviewers = (reviewers: GRBReviewerFields[]) => diff --git a/src/views/GovernanceReviewTeam/GRBReview/index.tsx b/src/views/GovernanceReviewTeam/GRBReview/index.tsx index 23ec2cd731..84d6bec494 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/index.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/index.tsx @@ -12,7 +12,7 @@ import { ModalHeading } from '@trussworks/react-uswds'; import { - GetSystemIntakeGRBReviewDocument, + GetSystemIntakeGRBReviewersDocument, SystemIntakeGRBReviewerFragment, useDeleteSystemIntakeGRBReviewerMutation, useStartGRBReviewMutation @@ -80,7 +80,7 @@ const GRBReview = ({ const { showMessage } = useMessage(); const [mutate] = useDeleteSystemIntakeGRBReviewerMutation({ - refetchQueries: [GetSystemIntakeGRBReviewDocument] + refetchQueries: [GetSystemIntakeGRBReviewersDocument] }); const [startGRBReview] = useStartGRBReviewMutation({ @@ -91,7 +91,7 @@ const GRBReview = ({ }, refetchQueries: [ { - query: GetSystemIntakeGRBReviewDocument, + query: GetSystemIntakeGRBReviewersDocument, variables: { id } } ] @@ -361,6 +361,7 @@ const GRBReview = ({ diff --git a/src/views/GovernanceReviewTeam/index.tsx b/src/views/GovernanceReviewTeam/index.tsx index a06c90d064..269f9bd579 100644 --- a/src/views/GovernanceReviewTeam/index.tsx +++ b/src/views/GovernanceReviewTeam/index.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { Route, Switch, useParams } from 'react-router-dom'; -import { useGetSystemIntakeGRBReviewQuery } from 'gql/gen/graphql'; +import { useGetSystemIntakeGRBReviewersQuery } from 'gql/gen/graphql'; import { useFlags } from 'launchdarkly-react-client-sdk'; import PageLoading from 'components/PageLoading'; @@ -24,7 +24,7 @@ const GovernanceReviewTeam = () => { id: string; }>(); - const { data, loading } = useGetSystemIntakeGRBReviewQuery({ + const { data, loading } = useGetSystemIntakeGRBReviewersQuery({ variables: { id } From 09d5f2477c1e685d1f0448d58461b59f358153d9 Mon Sep 17 00:00:00 2001 From: Lee Warrick Date: Thu, 12 Dec 2024 17:58:19 -0500 Subject: [PATCH 19/22] remove self-tagging emails and add tests for discussion emails --- pkg/graph/resolvers/resolver_test.go | 11 +- .../system_intake_grb_discussions.go | 33 +- .../system_intake_grb_discussions_test.go | 576 +++++++++++------- .../system_intake_grb_reviewer_test.go | 2 +- 4 files changed, 386 insertions(+), 236 deletions(-) diff --git a/pkg/graph/resolvers/resolver_test.go b/pkg/graph/resolvers/resolver_test.go index 4706b23e21..3f621151fa 100644 --- a/pkg/graph/resolvers/resolver_test.go +++ b/pkg/graph/resolvers/resolver_test.go @@ -161,10 +161,9 @@ func (tc *TestConfigs) GetDefaults() { tc.EmailClient = emailClient } -func NewEmailClient() (*email.Client, *mockSender) { - sender := &mockSender{} +func getTestEmailConfig() email.Config { config := testhelpers.NewConfig() - emailConfig := email.Config{ + return email.Config{ GRTEmail: models.NewEmailAddress(config.GetString(appconfig.GRTEmailKey)), ITInvestmentEmail: models.NewEmailAddress(config.GetString(appconfig.ITInvestmentEmailKey)), TRBEmail: models.NewEmailAddress(config.GetString(appconfig.TRBEmailKey)), @@ -173,7 +172,11 @@ func NewEmailClient() (*email.Client, *mockSender) { URLScheme: config.GetString(appconfig.ClientProtocolKey), TemplateDirectory: config.GetString(appconfig.EmailTemplateDirectoryKey), } +} +func NewEmailClient() (*email.Client, *mockSender) { + sender := &mockSender{} + emailConfig := getTestEmailConfig() emailClient, _ := email.NewClient(emailConfig, sender) return &emailClient, sender } @@ -269,7 +272,7 @@ func (s *ResolverSuite) getOrCreateUserAcct(euaUserID string) *authentication.Us } // utility method to get userAcct in resolver tests -func (s *ResolverSuite) getOrCreateUserAccts(euaUserIDs []string) []*authentication.UserAccount { +func (s *ResolverSuite) getOrCreateUserAccts(euaUserIDs ...string) []*authentication.UserAccount { ctx := s.testConfigs.Context store := s.testConfigs.Store okta := local.NewOktaAPIClient() diff --git a/pkg/graph/resolvers/system_intake_grb_discussions.go b/pkg/graph/resolvers/system_intake_grb_discussions.go index f1e46235a8..cd75d32026 100644 --- a/pkg/graph/resolvers/system_intake_grb_discussions.go +++ b/pkg/graph/resolvers/system_intake_grb_discussions.go @@ -165,20 +165,24 @@ func CreateSystemIntakeGRBDiscussionReply( if err != nil { return nil, err } - if err := emailClient.SystemIntake.SendGRBReviewDiscussionReplyEmail(ctx, email.SendGRBReviewDiscussionReplyEmailInput{ - SystemIntakeID: intakeID, - UserName: replyPoster.CommonName, - RequestName: systemIntake.ProjectName.String, - DiscussionID: initialPost.ID, - Role: authorRole, - DiscussionContent: input.Content.ToTemplate(), - Recipient: models.EmailAddress(initialPoster.Email), - }); err != nil { - return nil, err + + // don't send email to author if reply is from author + if initialPoster.ID != replyPoster.ID { + if err := emailClient.SystemIntake.SendGRBReviewDiscussionReplyEmail(ctx, email.SendGRBReviewDiscussionReplyEmailInput{ + SystemIntakeID: intakeID, + UserName: replyPoster.CommonName, + RequestName: systemIntake.ProjectName.String, + DiscussionID: initialPost.ID, + Role: authorRole, + DiscussionContent: input.Content.ToTemplate(), + Recipient: models.EmailAddress(initialPoster.Email), + }); err != nil { + return nil, err + } } uniqueTags := input.Content.UniqueTags() - // strip discussion author from tags in case author was tagged + // strip initial post author from tags in case author was tagged uniqueTagsWithoutInitialPoster := lo.Filter(uniqueTags, func(t *models.Tag, _ int) bool { return t.TaggedContentID != initialPoster.ID }) @@ -237,6 +241,13 @@ func sendDiscussionEmailsForTags( if grbReviewer == nil { continue } + // skip including author/current user in Reviewers to be tagged + // individual tags are also built from this cache below, so this will exclude the author from the + // GRB group tag email as well as an individual tag email + // GRT admin tags go to ITGov inbox, so an admin author will still receive an admin tag email + if grbReviewer.UserID == principal.Account().ID { + continue + } grbReviewerCache[grbReviewer.UserID] = grbReviewer } diff --git a/pkg/graph/resolvers/system_intake_grb_discussions_test.go b/pkg/graph/resolvers/system_intake_grb_discussions_test.go index 5e042c8bee..db157a889e 100644 --- a/pkg/graph/resolvers/system_intake_grb_discussions_test.go +++ b/pkg/graph/resolvers/system_intake_grb_discussions_test.go @@ -2,9 +2,12 @@ package resolvers import ( "context" + "fmt" "github.com/google/uuid" + "github.com/samber/lo" + "github.com/cms-enterprise/easi-app/pkg/appcontext" "github.com/cms-enterprise/easi-app/pkg/email" "github.com/cms-enterprise/easi-app/pkg/models" "github.com/cms-enterprise/easi-app/pkg/userhelpers" @@ -17,25 +20,13 @@ func (s *ResolverSuite) TestSystemIntakeGRBDiscussions() { emailClient, _ := NewEmailClient() intake := s.createNewIntake() - ctx, princ := s.getTestContextWithPrincipal("ABCD", true) - post, err := CreateSystemIntakeGRBDiscussionPost( + ctx, _ := s.getTestContextWithPrincipal("ABCD", true) + post := s.createGRBDiscussion( ctx, - store, emailClient, - models.CreateSystemIntakeGRBDiscussionPostInput{ - SystemIntakeID: intake.ID, - Content: models.TaggedHTML{ - RawContent: "

      banana

      ", - }, - }, + intake.ID, + "

      banana

      ", ) - s.NotNil(post) - s.NoError(err) - s.Equal(post.Content, models.HTML("

      banana

      ")) - s.Equal(post.SystemIntakeID, intake.ID) - s.Equal(princ.UserAccount.ID, post.CreatedBy) - // initial discussions should have no reply ID - s.Nil(post.ReplyToID) // test the resolver for retrieving discussions discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) @@ -52,69 +43,28 @@ func (s *ResolverSuite) TestSystemIntakeGRBDiscussions() { emailClient, _ := NewEmailClient() intake := s.createNewIntake() - ctx, princ := s.getTestContextWithPrincipal("ABCD", true) - post, err := CreateSystemIntakeGRBDiscussionPost( + ctx, _ := s.getTestContextWithPrincipal("ABCD", true) + s.createGRBDiscussion( ctx, - store, emailClient, - models.CreateSystemIntakeGRBDiscussionPostInput{ - SystemIntakeID: intake.ID, - Content: models.TaggedHTML{ - RawContent: "

      banana

      ", - }, - }, + intake.ID, + "

      banana

      ", ) - s.NotNil(post) - s.NoError(err) - s.Equal(post.Content, models.HTML("

      banana

      ")) - s.Equal(post.SystemIntakeID, intake.ID) - s.Equal(princ.UserAccount.ID, post.CreatedBy) - // initial discussions should have no reply ID - s.Nil(post.ReplyToID) + }) s.Run("create GRB discussion and add to intake as reviewer", func() { emailClient, _ := NewEmailClient() - intake := s.createNewIntake() + intake, _ := s.createIntakeAndAddReviewersByEUAs("ABCD") - _, err := CreateSystemIntakeGRBReviewers( - s.testConfigs.Context, - store, - emailClient, - userhelpers.GetUserInfoAccountInfosWrapperFunc(s.testConfigs.UserSearchClient.FetchUserInfos), - &models.CreateSystemIntakeGRBReviewersInput{ - SystemIntakeID: intake.ID, - Reviewers: []*models.CreateGRBReviewerInput{ - { - EuaUserID: "ABCD", - VotingRole: models.SystemIntakeGRBReviewerVotingRoleVoting, - GrbRole: models.SystemIntakeGRBReviewerRoleCoChairCfo, - }, - }, - }, - ) - s.NoError(err) - - ctx, princ := s.getTestContextWithPrincipal("ABCD", false) - post, err := CreateSystemIntakeGRBDiscussionPost( + ctx, _ := s.getTestContextWithPrincipal("ABCD", false) + s.createGRBDiscussion( ctx, - store, emailClient, - models.CreateSystemIntakeGRBDiscussionPostInput{ - SystemIntakeID: intake.ID, - Content: models.TaggedHTML{ - RawContent: "

      banana

      ", - }, - }, + intake.ID, + "

      banana

      ", ) - s.NotNil(post) - s.NoError(err) - s.Equal(post.Content, models.HTML("

      banana

      ")) - s.Equal(post.SystemIntakeID, intake.ID) - s.Equal(princ.UserAccount.ID, post.CreatedBy) - // initial discussions should have no reply ID - s.Nil(post.ReplyToID) }) s.Run("cannot create GRB discussion if not reviewer or admin", func() { @@ -136,94 +86,109 @@ func (s *ResolverSuite) TestSystemIntakeGRBDiscussions() { s.Nil(post) s.Error(err) }) -} - -func (s *ResolverSuite) TestSystemIntakeGRBDiscussionReplies() { - store := s.testConfigs.Store - // helper to create an intake and add GRB reviewers at the same time - createIntakeAndAddReviewers := func(reviewerEuaIDs ...string) *models.SystemIntake { - intake := s.createNewIntake() + s.Run("tagged reviewers should receive an email", func() { + emailClient, sender := NewEmailClient() - reviewers := []*models.CreateGRBReviewerInput{} - for _, reviewerEUA := range reviewerEuaIDs { - reviewers = append(reviewers, &models.CreateGRBReviewerInput{ - EuaUserID: reviewerEUA, - VotingRole: models.SystemIntakeGRBReviewerVotingRoleVoting, - GrbRole: models.SystemIntakeGRBReviewerRoleCoChairCfo, - }) - } + intake, _ := s.createIntakeAndAddReviewersByEUAs("ABCD", "BTMN", "USR2") - if len(reviewers) > 0 { - _, err := CreateSystemIntakeGRBReviewers( - s.testConfigs.Context, - store, - nil, //email client - userhelpers.GetUserInfoAccountInfosWrapperFunc(s.testConfigs.UserSearchClient.FetchUserInfos), - &models.CreateSystemIntakeGRBReviewersInput{ - SystemIntakeID: intake.ID, - Reviewers: reviewers, + ctx, _ := s.getTestContextWithPrincipal("ABCD", false) + content := "

      banana

      " + + usersToEmail := s.getOrCreateUserAccts("BTMN", "USR2") + for _, user := range usersToEmail { + content = addTags( + content, + models.Tag{ + TagType: models.TagTypeUserAccount, + TaggedContentID: user.ID, }, ) - s.NoError(err) } - return intake - } - // helper to create a discussion - createDiscussion := func( - ctx context.Context, - emailClient *email.Client, - intakeID uuid.UUID, - content string, - ) *models.SystemIntakeGRBReviewDiscussionPost { - discussion, err := CreateSystemIntakeGRBDiscussionPost( + s.createGRBDiscussion( ctx, - store, emailClient, - models.CreateSystemIntakeGRBDiscussionPostInput{ - SystemIntakeID: intakeID, - Content: models.TaggedHTML{ - RawContent: models.HTML(content), - }, + intake.ID, + content, + ) + + s.Len(sender.sentEmails, 1) + s.Equal(sender.subject, fmt.Sprintf("You were tagged in a GRB Review discussion for %s", intake.ProjectName.String)) + // should exclude ABCD as author of reply + s.Len(sender.bccAddresses, 2) + for _, user := range usersToEmail { + s.Contains(sender.bccAddresses, models.EmailAddress(user.Email)) + } + }) + + s.Run("tagging groups should send emails", func() { + emailClient, sender := NewEmailClient() + + intake, _ := s.createIntakeAndAddReviewersByEUAs("ABCD", "BTMN", "USR2") + + ctx, _ := s.getTestContextWithPrincipal("ABCD", false) + content := "

      banana

      " + + // author should be excluded from emails + reviewerAccts := s.getOrCreateUserAccts("BTMN", "USR2") + + content = addTags( + content, + models.Tag{ + TagType: models.TagTypeGroupGrbReviewers, + }, + models.Tag{ + TagType: models.TagTypeGroupItGov, }, ) - s.NotNil(discussion) - s.NoError(err) - s.Equal(discussion.Content, models.HTML(content)) - s.Equal(discussion.SystemIntakeID, intakeID) - s.NotNil(discussion.ID) - s.Nil(discussion.ReplyToID) - return discussion - } + + s.createGRBDiscussion( + ctx, + emailClient, + intake.ID, + content, + ) + + s.Len(sender.sentEmails, 2) + grtEmail, found := lo.Find(sender.sentEmails, func(email email.Email) bool { + return email.Subject == fmt.Sprintf("The Governance Admin Team was tagged in a GRB Review discussion for %s", intake.ProjectName.String) + }) + s.True(found) + s.Len(grtEmail.CcAddresses, 1) + s.Equal(grtEmail.CcAddresses[0], getTestEmailConfig().GRTEmail) + + grbEmail, found := lo.Find(sender.sentEmails, func(email email.Email) bool { + return email.Subject == fmt.Sprintf("The GRB was tagged in a GRB Review discussion for %s", intake.ProjectName.String) + }) + + s.True(found) + s.NotNil(grbEmail) + // should exclude ABCD as author of reply + s.Len(grbEmail.BccAddresses, len(reviewerAccts)) + for _, user := range reviewerAccts { + s.Contains(grbEmail.BccAddresses, models.EmailAddress(user.Email)) + } + }) +} + +func (s *ResolverSuite) TestSystemIntakeGRBDiscussionReplies() { + store := s.testConfigs.Store s.Run("reply to GRB discussion as admin", func() { emailClient, _ := NewEmailClient() - intake := createIntakeAndAddReviewers() + intake := s.createNewIntake() ctx, princ := s.getTestContextWithPrincipal("USR1", true) - discussionPost := createDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") - replyPost, err := CreateSystemIntakeGRBDiscussionReply( + replyPost := s.createGRBDiscussionReply( ctx, - store, emailClient, - models.CreateSystemIntakeGRBDiscussionReplyInput{ - InitialPostID: discussionPost.ID, - Content: models.TaggedHTML{ - RawContent: "

      banana

      ", - }, - }, + discussionPost, + "

      banana

      ", ) - // test returned reply post - s.NotNil(replyPost) - s.NoError(err) - s.Equal(replyPost.Content, models.HTML("

      banana

      ")) - s.Equal(replyPost.SystemIntakeID, intake.ID) - s.Equal(princ.UserAccount.ID, replyPost.CreatedBy) - s.NotNil(replyPost.ReplyToID) - s.Equal(*replyPost.ReplyToID, discussionPost.ID) // fetch discussion using resolver discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) @@ -236,6 +201,7 @@ func (s *ResolverSuite) TestSystemIntakeGRBDiscussionReplies() { // test discussion reply from resolver s.Len(discussions[0].Replies, 1) reply := discussions[0].Replies[0] + s.Equal(reply.ID, replyPost.ID) s.Equal(princ.UserAccount.ID, reply.CreatedBy) s.NotNil(reply.ReplyToID) s.Equal(discussion.InitialPost.ID, *reply.ReplyToID) @@ -245,31 +211,18 @@ func (s *ResolverSuite) TestSystemIntakeGRBDiscussionReplies() { s.Run("reply to GRB discussion as reviewer", func() { emailClient, _ := NewEmailClient() - intake := createIntakeAndAddReviewers("BTMN") + intake, _ := s.createIntakeAndAddReviewersByEUAs("BTMN") ctx, discAuthor := s.getTestContextWithPrincipal("USR1", true) - discussionPost := createDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") ctx, replyAuthor := s.getTestContextWithPrincipal("BTMN", false) - replyPost, err := CreateSystemIntakeGRBDiscussionReply( + replyPost := s.createGRBDiscussionReply( ctx, - store, emailClient, - models.CreateSystemIntakeGRBDiscussionReplyInput{ - InitialPostID: discussionPost.ID, - Content: models.TaggedHTML{ - RawContent: "

      banana

      ", - }, - }, + discussionPost, + "

      banana

      ", ) - // test returned reply post - s.NotNil(replyPost) - s.NoError(err) - s.Equal(replyPost.Content, models.HTML("

      banana

      ")) - s.Equal(replyPost.SystemIntakeID, intake.ID) - s.Equal(replyAuthor.UserAccount.ID, replyPost.CreatedBy) - s.NotNil(replyPost.ReplyToID) - s.Equal(*replyPost.ReplyToID, discussionPost.ID) // fetch discussion using resolver discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) @@ -283,6 +236,7 @@ func (s *ResolverSuite) TestSystemIntakeGRBDiscussionReplies() { // test discussion reply from resolver s.Len(discussions[0].Replies, 1) reply := discussions[0].Replies[0] + s.Equal(reply.ID, replyPost.ID) s.Equal(replyAuthor.UserAccount.ID, reply.CreatedBy) s.NotNil(reply.ReplyToID) s.Equal(discussion.InitialPost.ID, *reply.ReplyToID) @@ -292,10 +246,10 @@ func (s *ResolverSuite) TestSystemIntakeGRBDiscussionReplies() { s.Run("should not allow reply to GRB discussion when not reviewer nor admin", func() { emailClient, _ := NewEmailClient() - intake := createIntakeAndAddReviewers() + intake := s.createNewIntake() ctx, discAuthor := s.getTestContextWithPrincipal("USR1", true) - discussionPost := createDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") ctx, _ = s.getTestContextWithPrincipal("USR2", false) replyPost, err := CreateSystemIntakeGRBDiscussionReply( @@ -328,51 +282,26 @@ func (s *ResolverSuite) TestSystemIntakeGRBDiscussionReplies() { s.Run("Should allow for multiple replies", func() { emailClient, _ := NewEmailClient() - intake := createIntakeAndAddReviewers("BTMN", "ABCD") + intake, _ := s.createIntakeAndAddReviewersByEUAs("BTMN", "ABCD") ctx, discAuthor := s.getTestContextWithPrincipal("USR1", true) - discussionPost := createDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") ctx, reply1Author := s.getTestContextWithPrincipal("BTMN", false) - reply1Post, err := CreateSystemIntakeGRBDiscussionReply( + reply1Post := s.createGRBDiscussionReply( ctx, - store, emailClient, - models.CreateSystemIntakeGRBDiscussionReplyInput{ - InitialPostID: discussionPost.ID, - Content: models.TaggedHTML{ - RawContent: "

      banana

      ", - }, - }, + discussionPost, + "

      banana

      ", ) - s.NotNil(reply1Post) - s.NoError(err) - s.Equal(reply1Post.Content, models.HTML("

      banana

      ")) - s.Equal(reply1Post.SystemIntakeID, intake.ID) - s.Equal(reply1Author.UserAccount.ID, reply1Post.CreatedBy) - s.NotNil(reply1Post.ReplyToID) - s.Equal(*reply1Post.ReplyToID, discussionPost.ID) ctx, reply2Author := s.getTestContextWithPrincipal("ABCD", false) - reply2Post, err := CreateSystemIntakeGRBDiscussionReply( + reply2Post := s.createGRBDiscussionReply( ctx, - store, emailClient, - models.CreateSystemIntakeGRBDiscussionReplyInput{ - InitialPostID: discussionPost.ID, - Content: models.TaggedHTML{ - RawContent: "

      tangerine

      ", - }, - }, + discussionPost, + "

      tangerine

      ", ) - // test reply from mutation - s.NotNil(reply2Post) - s.NoError(err) - s.Equal(reply2Post.Content, models.HTML("

      tangerine

      ")) - s.Equal(reply2Post.SystemIntakeID, intake.ID) - s.Equal(reply2Author.UserAccount.ID, reply2Post.CreatedBy) - s.NotNil(reply2Post.ReplyToID) - s.Equal(*reply2Post.ReplyToID, discussionPost.ID) // fetch discussion using resolver discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) @@ -387,12 +316,14 @@ func (s *ResolverSuite) TestSystemIntakeGRBDiscussionReplies() { s.Len(discussions[0].Replies, 2) reply1 := discussions[0].Replies[0] + s.Equal(reply1Post.ID, reply1.ID) s.Equal(reply1Author.UserAccount.ID, reply1.CreatedBy) s.NotNil(reply1.ReplyToID) s.Equal(discussion.InitialPost.ID, *reply1.ReplyToID) s.Equal(reply1.Content, models.HTML("

      banana

      ")) reply2 := discussions[0].Replies[1] + s.Equal(reply2Post.ID, reply2.ID) s.Equal(reply2Author.UserAccount.ID, reply2.CreatedBy) s.NotNil(reply2.ReplyToID) s.Equal(discussion.InitialPost.ID, *reply2.ReplyToID) @@ -402,56 +333,32 @@ func (s *ResolverSuite) TestSystemIntakeGRBDiscussionReplies() { s.Run("Should allow for replies on different discussions", func() { emailClient, _ := NewEmailClient() - intake := createIntakeAndAddReviewers("BTMN", "ABCD") + intake, _ := s.createIntakeAndAddReviewersByEUAs("BTMN", "ABCD") // create two discussions ctx, disc1Author := s.getTestContextWithPrincipal("USR1", true) - discussion1Post := createDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") + discussion1Post := s.createGRBDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") ctx, disc2Author := s.getTestContextWithPrincipal("USR2", true) - discussion2Post := createDiscussion(ctx, emailClient, intake.ID, "

      this is a second discussion

      ") + discussion2Post := s.createGRBDiscussion(ctx, emailClient, intake.ID, "

      this is a second discussion

      ") // reply to the first discussion ctx, reply1Author := s.getTestContextWithPrincipal("BTMN", false) - reply1Post, err := CreateSystemIntakeGRBDiscussionReply( + reply1Post := s.createGRBDiscussionReply( ctx, - store, emailClient, - models.CreateSystemIntakeGRBDiscussionReplyInput{ - InitialPostID: discussion1Post.ID, - Content: models.TaggedHTML{ - RawContent: "

      banana

      ", - }, - }, + discussion1Post, + "

      banana

      ", ) - s.NotNil(reply1Post) - s.NoError(err) - s.Equal(reply1Post.Content, models.HTML("

      banana

      ")) - s.Equal(reply1Post.SystemIntakeID, intake.ID) - s.Equal(reply1Author.UserAccount.ID, reply1Post.CreatedBy) - s.NotNil(reply1Post.ReplyToID) - s.Equal(*reply1Post.ReplyToID, discussion1Post.ID) // reply to the second discussion ctx, reply2Author := s.getTestContextWithPrincipal("ABCD", false) - reply2Post, err := CreateSystemIntakeGRBDiscussionReply( + reply2Post := s.createGRBDiscussionReply( ctx, - store, emailClient, - models.CreateSystemIntakeGRBDiscussionReplyInput{ - InitialPostID: discussion2Post.ID, - Content: models.TaggedHTML{ - RawContent: "

      tangerine

      ", - }, - }, + discussion2Post, + "

      tangerine

      ", ) - s.NotNil(reply2Post) - s.NoError(err) - s.Equal(reply2Post.Content, models.HTML("

      tangerine

      ")) - s.Equal(reply2Post.SystemIntakeID, intake.ID) - s.Equal(reply2Author.UserAccount.ID, reply2Post.CreatedBy) - s.NotNil(reply2Post.ReplyToID) - s.Equal(*reply2Post.ReplyToID, discussion2Post.ID) // fetch discussions using resolver discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) @@ -470,15 +377,244 @@ func (s *ResolverSuite) TestSystemIntakeGRBDiscussionReplies() { s.Len(discussions[1].Replies, 1) reply1 := discussion1.Replies[0] + s.Equal(reply1.ID, reply1Post.ID) s.Equal(reply1Author.UserAccount.ID, reply1.CreatedBy) s.NotNil(reply1.ReplyToID) s.Equal(discussion1.InitialPost.ID, *reply1.ReplyToID) s.Equal(reply1.Content, models.HTML("

      banana

      ")) reply2 := discussion2.Replies[0] + s.Equal(reply2.ID, reply2Post.ID) s.Equal(reply2Author.UserAccount.ID, reply2.CreatedBy) s.NotNil(reply2.ReplyToID) s.Equal(discussion2.InitialPost.ID, *reply2.ReplyToID) s.Equal(reply2.Content, models.HTML("

      tangerine

      ")) }) + + s.Run("replies should email discussion author", func() { + emailClient, sender := NewEmailClient() + + intake := s.createNewIntake() + + ctx, discussionAuthor := s.getTestContextWithPrincipal("USR1", true) + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") + + ctx, _ = s.getTestContextWithPrincipal("USR2", true) + s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + "

      banana

      ", + ) + + s.True(sender.emailWasSent) + s.Len(sender.toAddresses, 1) + s.Equal(discussionAuthor.Account().Email, sender.toAddresses[0].String()) + }) + + s.Run("author replies should NOT email discussion author", func() { + emailClient, sender := NewEmailClient() + + intake := s.createNewIntake() + + ctx, _ := s.getTestContextWithPrincipal("USR1", true) + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") + + s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + "

      banana

      ", + ) + + s.False(sender.emailWasSent) + }) + + s.Run("individual tags in replies should send an email to those users", func() { + emailClient, sender := NewEmailClient() + + intake, _ := s.createIntakeAndAddReviewersByEUAs("USR1", "BTMN", "ABCD") + + ctx, _ := s.getTestContextWithPrincipal("USR1", true) + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "

      this is a discussion

      ") + + replyContent := "

      banana

      " + usersToEmail := s.getOrCreateUserAccts("BTMN", "ABCD") + for _, user := range usersToEmail { + replyContent = addTags( + replyContent, + models.Tag{ + TagType: models.TagTypeUserAccount, + TaggedContentID: user.ID, + }, + ) + } + s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + replyContent, + ) + + s.True(sender.emailWasSent) + s.Equal(sender.subject, fmt.Sprintf("You were tagged in a GRB Review discussion for %s", intake.ProjectName.String)) + // should exclude ABCD as author of reply and initial discussion post + s.Len(sender.bccAddresses, 2) + for _, user := range usersToEmail { + s.Contains(sender.bccAddresses, models.EmailAddress(user.Email)) + } + }) + s.Run("tagging groups in replies should send emails", func() { + emailClient, sender := NewEmailClient() + + intake, _ := s.createIntakeAndAddReviewersByEUAs("ABCD", "BTMN", "USR2") + + ctx, _ := s.getTestContextWithPrincipal("ABCD", false) + content := "

      banana

      " + discussionPost := s.createGRBDiscussion( + ctx, + emailClient, + intake.ID, + content, + ) + + // author should be excluded from emails + reviewerAccts := s.getOrCreateUserAccts("BTMN", "USR2") + + content = addTags( + content, + models.Tag{ + TagType: models.TagTypeGroupGrbReviewers, + }, + models.Tag{ + TagType: models.TagTypeGroupItGov, + }, + ) + s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + content, + ) + + // should not send reply email as initial post and reply share an author + s.Len(sender.sentEmails, 2) + grtEmail, found := lo.Find(sender.sentEmails, func(email email.Email) bool { + return email.Subject == fmt.Sprintf("The Governance Admin Team was tagged in a GRB Review discussion for %s", intake.ProjectName.String) + }) + s.True(found) + s.Len(grtEmail.CcAddresses, 1) + s.Equal(grtEmail.CcAddresses[0], getTestEmailConfig().GRTEmail) + + grbEmail, found := lo.Find(sender.sentEmails, func(email email.Email) bool { + return email.Subject == fmt.Sprintf("The GRB was tagged in a GRB Review discussion for %s", intake.ProjectName.String) + }) + + s.True(found) + s.NotNil(grbEmail) + // should exclude ABCD as author of reply + s.Len(grbEmail.BccAddresses, len(reviewerAccts)) + for _, user := range reviewerAccts { + s.Contains(grbEmail.BccAddresses, models.EmailAddress(user.Email)) + } + }) +} + +// helper to create a discussion +func (s *ResolverSuite) createGRBDiscussion( + ctx context.Context, + emailClient *email.Client, + intakeID uuid.UUID, + content string, +) *models.SystemIntakeGRBReviewDiscussionPost { + taggedHTMLContent, err := models.NewTaggedHTMLFromString(content) + s.NoError(err) + discussion, err := CreateSystemIntakeGRBDiscussionPost( + ctx, + s.testConfigs.Store, + emailClient, + models.CreateSystemIntakeGRBDiscussionPostInput{ + SystemIntakeID: intakeID, + Content: taggedHTMLContent, + }, + ) + s.NotNil(discussion) + s.NoError(err) + s.Equal(discussion.Content, models.HTML(content)) + s.Equal(discussion.SystemIntakeID, intakeID) + s.NotNil(discussion.ID) + s.Nil(discussion.ReplyToID) + return discussion +} + +func (s *ResolverSuite) createGRBDiscussionReply( + ctx context.Context, + emailClient *email.Client, + discussionPost *models.SystemIntakeGRBReviewDiscussionPost, + content string, +) *models.SystemIntakeGRBReviewDiscussionPost { + taggedHTMLContent, err := models.NewTaggedHTMLFromString(content) + s.NoError(err) + replyPost, err := CreateSystemIntakeGRBDiscussionReply( + ctx, + s.testConfigs.Store, + emailClient, + models.CreateSystemIntakeGRBDiscussionReplyInput{ + InitialPostID: discussionPost.ID, + Content: taggedHTMLContent, + }, + ) + // test returned reply post + s.NotNil(replyPost) + s.NoError(err) + s.Equal(replyPost.Content, models.HTML(content)) + s.Equal(replyPost.SystemIntakeID, discussionPost.SystemIntakeID) + s.Equal(appcontext.Principal(ctx).Account().ID, replyPost.CreatedBy) + s.NotNil(replyPost.ReplyToID) + s.Equal(*replyPost.ReplyToID, discussionPost.ID) + return replyPost +} + +// helper to create an intake and add GRB reviewers at the same time +func (s *ResolverSuite) createIntakeAndAddReviewersByEUAs(reviewerEuaIDs ...string) (*models.SystemIntake, []*models.SystemIntakeGRBReviewer) { + intake := s.createNewIntake() + + reviewers := []*models.CreateGRBReviewerInput{} + for _, reviewerEUA := range reviewerEuaIDs { + reviewers = append(reviewers, &models.CreateGRBReviewerInput{ + EuaUserID: reviewerEUA, + VotingRole: models.SystemIntakeGRBReviewerVotingRoleVoting, + GrbRole: models.SystemIntakeGRBReviewerRoleCoChairCfo, + }) + } + + var createdReviewers []*models.SystemIntakeGRBReviewer + if len(reviewers) > 0 { + payload, err := CreateSystemIntakeGRBReviewers( + s.testConfigs.Context, + s.testConfigs.Store, + nil, //email client + userhelpers.GetUserInfoAccountInfosWrapperFunc(s.testConfigs.UserSearchClient.FetchUserInfos), + &models.CreateSystemIntakeGRBReviewersInput{ + SystemIntakeID: intake.ID, + Reviewers: reviewers, + }, + ) + s.NoError(err) + s.Equal(len(payload.Reviewers), len(reviewerEuaIDs)) + createdReviewers = payload.Reviewers + } + return intake, createdReviewers +} + +func addTags(htmlString string, tags ...models.Tag) string { + for _, tag := range tags { + span := fmt.Sprintf(`@tag` + htmlString += span + } + return htmlString } diff --git a/pkg/graph/resolvers/system_intake_grb_reviewer_test.go b/pkg/graph/resolvers/system_intake_grb_reviewer_test.go index d44d3d2c67..a4ad16abf5 100644 --- a/pkg/graph/resolvers/system_intake_grb_reviewer_test.go +++ b/pkg/graph/resolvers/system_intake_grb_reviewer_test.go @@ -28,7 +28,7 @@ func (s *ResolverSuite) TestSystemIntakeGRBReviewer() { grbRole2 := models.SystemIntakeGRBReviewerRoleFedAdminBdgChair intake := s.createNewIntake() - userAccts := s.getOrCreateUserAccts([]string{reviewerEUA1, reviewerEUA2}) + userAccts := s.getOrCreateUserAccts(reviewerEUA1, reviewerEUA2) payload, err := CreateSystemIntakeGRBReviewers( ctx, From ed012c2a78459ee489ffd13d674441e1e2d0aed0 Mon Sep 17 00:00:00 2001 From: ClayBenson94 Date: Fri, 13 Dec 2024 08:49:45 -0500 Subject: [PATCH 20/22] Fix typo and content in GRB Discussion usage tips --- src/i18n/en-US/discussions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/en-US/discussions.ts b/src/i18n/en-US/discussions.ts index 3e6f6b7252..d282b5f921 100644 --- a/src/i18n/en-US/discussions.ts +++ b/src/i18n/en-US/discussions.ts @@ -73,7 +73,7 @@ const discussions = { label: 'Tips for using the discussion boards', content: [ 'Start a new discussion thread for each new topic', - 'Use tags (@) any time you need input from a specific individual or group. Group tags will notify all members of that group. Avalailble group tags: @Governance Review Board, @Governance Admin Team, and @Admin Lead', + 'Use tags (@) any time you need input from a specific individual or group. Group tags will notify all members of that group. Available group tags: @Governance Review Board and @Governance Admin Team.', 'Participating individuals will get an email notification when a new discussion is started, or when they are tagged in a discussion or reply' ] } From e197322a3749a9d79ac3916855bda9de8471e0f7 Mon Sep 17 00:00:00 2001 From: ClayBenson94 Date: Fri, 13 Dec 2024 09:34:42 -0500 Subject: [PATCH 21/22] Fix duplicate role types on GRB Reviewers (and delete associated resolvers) --- pkg/graph/generated/generated.go | 89 +++---------------- .../resolvers/system_intake_grb_reviewer.go | 4 +- .../system_intake_grb_reviewer_test.go | 4 +- pkg/graph/schema.resolvers.go | 24 ----- pkg/models/system_intake_grb_discussions.go | 10 +-- pkg/models/system_intake_grb_reviewers.go | 69 +++++--------- 6 files changed, 42 insertions(+), 158 deletions(-) diff --git a/pkg/graph/generated/generated.go b/pkg/graph/generated/generated.go index f4432b285b..26f3237c9e 100644 --- a/pkg/graph/generated/generated.go +++ b/pkg/graph/generated/generated.go @@ -54,7 +54,6 @@ type ResolverRoot interface { Query() QueryResolver SystemIntake() SystemIntakeResolver SystemIntakeDocument() SystemIntakeDocumentResolver - SystemIntakeGRBReviewDiscussionPost() SystemIntakeGRBReviewDiscussionPostResolver SystemIntakeGRBReviewer() SystemIntakeGRBReviewerResolver SystemIntakeNote() SystemIntakeNoteResolver TRBAdminNote() TRBAdminNoteResolver @@ -852,7 +851,7 @@ type ComplexityRoot struct { Content func(childComplexity int) int CreatedAt func(childComplexity int) int CreatedByUserAccount func(childComplexity int) int - GrbRole func(childComplexity int) int + GRBRole func(childComplexity int) int ID func(childComplexity int) int ModifiedAt func(childComplexity int) int ModifiedByUserAccount func(childComplexity int) int @@ -1380,10 +1379,6 @@ type SystemIntakeDocumentResolver interface { CanDelete(ctx context.Context, obj *models.SystemIntakeDocument) (bool, error) CanView(ctx context.Context, obj *models.SystemIntakeDocument) (bool, error) } -type SystemIntakeGRBReviewDiscussionPostResolver interface { - VotingRole(ctx context.Context, obj *models.SystemIntakeGRBReviewDiscussionPost) (*models.SystemIntakeGRBReviewerVotingRole, error) - GrbRole(ctx context.Context, obj *models.SystemIntakeGRBReviewDiscussionPost) (*models.SystemIntakeGRBReviewerRole, error) -} type SystemIntakeGRBReviewerResolver interface { VotingRole(ctx context.Context, obj *models.SystemIntakeGRBReviewer) (models.SystemIntakeGRBReviewerVotingRole, error) GrbRole(ctx context.Context, obj *models.SystemIntakeGRBReviewer) (models.SystemIntakeGRBReviewerRole, error) @@ -6237,11 +6232,11 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.SystemIntakeGRBReviewDiscussionPost.CreatedByUserAccount(childComplexity), true case "SystemIntakeGRBReviewDiscussionPost.grbRole": - if e.complexity.SystemIntakeGRBReviewDiscussionPost.GrbRole == nil { + if e.complexity.SystemIntakeGRBReviewDiscussionPost.GRBRole == nil { break } - return e.complexity.SystemIntakeGRBReviewDiscussionPost.GrbRole(childComplexity), true + return e.complexity.SystemIntakeGRBReviewDiscussionPost.GRBRole(childComplexity), true case "SystemIntakeGRBReviewDiscussionPost.id": if e.complexity.SystemIntakeGRBReviewDiscussionPost.ID == nil { @@ -47847,7 +47842,7 @@ func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_votingRole(ctx }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.SystemIntakeGRBReviewDiscussionPost().VotingRole(rctx, obj) + return obj.VotingRole, nil }) if err != nil { ec.Error(ctx, err) @@ -47865,8 +47860,8 @@ func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_vot fc = &graphql.FieldContext{ Object: "SystemIntakeGRBReviewDiscussionPost", Field: field, - IsMethod: true, - IsResolver: true, + IsMethod: false, + IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { return nil, errors.New("field of type SystemIntakeGRBReviewerVotingRole does not have child fields") }, @@ -47888,7 +47883,7 @@ func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_grbRole(ctx con }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.SystemIntakeGRBReviewDiscussionPost().GrbRole(rctx, obj) + return obj.GRBRole, nil }) if err != nil { ec.Error(ctx, err) @@ -47906,8 +47901,8 @@ func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_grb fc = &graphql.FieldContext{ Object: "SystemIntakeGRBReviewDiscussionPost", Field: field, - IsMethod: true, - IsResolver: true, + IsMethod: false, + IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { return nil, errors.New("field of type SystemIntakeGRBReviewerRole does not have child fields") }, @@ -70486,71 +70481,9 @@ func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost(ctx context.Con atomic.AddUint32(&out.Invalids, 1) } case "votingRole": - field := field - - innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - } - }() - res = ec._SystemIntakeGRBReviewDiscussionPost_votingRole(ctx, field, obj) - return res - } - - if field.Deferrable != nil { - dfs, ok := deferred[field.Deferrable.Label] - di := 0 - if ok { - dfs.AddField(field) - di = len(dfs.Values) - 1 - } else { - dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) - deferred[field.Deferrable.Label] = dfs - } - dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { - return innerFunc(ctx, dfs) - }) - - // don't run the out.Concurrently() call below - out.Values[i] = graphql.Null - continue - } - - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_votingRole(ctx, field, obj) case "grbRole": - field := field - - innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - } - }() - res = ec._SystemIntakeGRBReviewDiscussionPost_grbRole(ctx, field, obj) - return res - } - - if field.Deferrable != nil { - dfs, ok := deferred[field.Deferrable.Label] - di := 0 - if ok { - dfs.AddField(field) - di = len(dfs.Values) - 1 - } else { - dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) - deferred[field.Deferrable.Label] = dfs - } - dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { - return innerFunc(ctx, dfs) - }) - - // don't run the out.Concurrently() call below - out.Values[i] = graphql.Null - continue - } - - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_grbRole(ctx, field, obj) case "systemIntakeID": out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_systemIntakeID(ctx, field, obj) if out.Values[i] == graphql.Null { diff --git a/pkg/graph/resolvers/system_intake_grb_reviewer.go b/pkg/graph/resolvers/system_intake_grb_reviewer.go index 620d2facbc..273d84bf15 100644 --- a/pkg/graph/resolvers/system_intake_grb_reviewer.go +++ b/pkg/graph/resolvers/system_intake_grb_reviewer.go @@ -67,8 +67,8 @@ func CreateSystemIntakeGRBReviewers( for _, acct := range accts { reviewerInput := reviewersByEUAMap[acct.Username] reviewer := models.NewSystemIntakeGRBReviewer(acct.ID, createdByID) - reviewer.GRBVotingRole = models.SIGRBReviewerVotingRole(reviewerInput.VotingRole) - reviewer.GRBReviewerRole = models.SIGRBReviewerRole(reviewerInput.GrbRole) + reviewer.GRBVotingRole = models.SystemIntakeGRBReviewerVotingRole(reviewerInput.VotingRole) + reviewer.GRBReviewerRole = models.SystemIntakeGRBReviewerRole(reviewerInput.GrbRole) reviewer.SystemIntakeID = input.SystemIntakeID reviewersToCreate = append(reviewersToCreate, reviewer) } diff --git a/pkg/graph/resolvers/system_intake_grb_reviewer_test.go b/pkg/graph/resolvers/system_intake_grb_reviewer_test.go index a4ad16abf5..c37764246e 100644 --- a/pkg/graph/resolvers/system_intake_grb_reviewer_test.go +++ b/pkg/graph/resolvers/system_intake_grb_reviewer_test.go @@ -99,8 +99,8 @@ func (s *ResolverSuite) TestSystemIntakeGRBReviewer() { reviewerEUA := "ABCD" originalVotingRole := models.SystemIntakeGRBReviewerVotingRoleAlternate originalGRBRole := models.SystemIntakeGRBReviewerRoleAca3021Rep - newVotingRole := models.SIGRBRVRVoting - newGRBRole := models.SIGRBRRCMCSRep + newVotingRole := models.SystemIntakeGRBReviewerVotingRoleVoting + newGRBRole := models.SystemIntakeGRBReviewerRoleCmcsRep userAcct := s.getOrCreateUserAcct(reviewerEUA) intake, reviewer := s.createIntakeAndAddReviewer(&models.CreateGRBReviewerInput{ diff --git a/pkg/graph/schema.resolvers.go b/pkg/graph/schema.resolvers.go index 9563653940..e6acb712b8 100644 --- a/pkg/graph/schema.resolvers.go +++ b/pkg/graph/schema.resolvers.go @@ -1964,24 +1964,6 @@ func (r *systemIntakeDocumentResolver) CanView(ctx context.Context, obj *models. return resolvers.CanViewDocument(ctx, grbUsers, obj), nil } -// VotingRole is the resolver for the votingRole field. -func (r *systemIntakeGRBReviewDiscussionPostResolver) VotingRole(ctx context.Context, obj *models.SystemIntakeGRBReviewDiscussionPost) (*models.SystemIntakeGRBReviewerVotingRole, error) { - if obj.VotingRole == nil { - return nil, nil - } - strVal := *obj.VotingRole - return helpers.PointerTo(models.SystemIntakeGRBReviewerVotingRole(strVal)), nil -} - -// GrbRole is the resolver for the grbRole field. -func (r *systemIntakeGRBReviewDiscussionPostResolver) GrbRole(ctx context.Context, obj *models.SystemIntakeGRBReviewDiscussionPost) (*models.SystemIntakeGRBReviewerRole, error) { - if obj.GRBRole == nil { - return nil, nil - } - strVal := *obj.GRBRole - return helpers.PointerTo(models.SystemIntakeGRBReviewerRole(strVal)), nil -} - // VotingRole is the resolver for the votingRole field. func (r *systemIntakeGRBReviewerResolver) VotingRole(ctx context.Context, obj *models.SystemIntakeGRBReviewer) (models.SystemIntakeGRBReviewerVotingRole, error) { return models.SystemIntakeGRBReviewerVotingRole(obj.GRBVotingRole), nil @@ -2270,11 +2252,6 @@ func (r *Resolver) SystemIntakeDocument() generated.SystemIntakeDocumentResolver return &systemIntakeDocumentResolver{r} } -// SystemIntakeGRBReviewDiscussionPost returns generated.SystemIntakeGRBReviewDiscussionPostResolver implementation. -func (r *Resolver) SystemIntakeGRBReviewDiscussionPost() generated.SystemIntakeGRBReviewDiscussionPostResolver { - return &systemIntakeGRBReviewDiscussionPostResolver{r} -} - // SystemIntakeGRBReviewer returns generated.SystemIntakeGRBReviewerResolver implementation. func (r *Resolver) SystemIntakeGRBReviewer() generated.SystemIntakeGRBReviewerResolver { return &systemIntakeGRBReviewerResolver{r} @@ -2335,7 +2312,6 @@ type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } type systemIntakeResolver struct{ *Resolver } type systemIntakeDocumentResolver struct{ *Resolver } -type systemIntakeGRBReviewDiscussionPostResolver struct{ *Resolver } type systemIntakeGRBReviewerResolver struct{ *Resolver } type systemIntakeNoteResolver struct{ *Resolver } type tRBAdminNoteResolver struct{ *Resolver } diff --git a/pkg/models/system_intake_grb_discussions.go b/pkg/models/system_intake_grb_discussions.go index 6ea628c066..8358dfeb7e 100644 --- a/pkg/models/system_intake_grb_discussions.go +++ b/pkg/models/system_intake_grb_discussions.go @@ -9,11 +9,11 @@ import ( type SystemIntakeGRBReviewDiscussionPost struct { BaseStructUser - Content HTML `json:"content" db:"content"` - SystemIntakeID uuid.UUID `json:"systemIntakeId" db:"system_intake_id"` - ReplyToID *uuid.UUID `db:"reply_to_id"` - VotingRole *SIGRBReviewerVotingRole `json:"votingRole" db:"voting_role"` - GRBRole *SIGRBReviewerRole `json:"grbRole" db:"grb_role"` + Content HTML `json:"content" db:"content"` + SystemIntakeID uuid.UUID `json:"systemIntakeId" db:"system_intake_id"` + ReplyToID *uuid.UUID `db:"reply_to_id"` + VotingRole *SystemIntakeGRBReviewerVotingRole `json:"votingRole" db:"voting_role"` + GRBRole *SystemIntakeGRBReviewerRole `json:"grbRole" db:"grb_role"` } func NewSystemIntakeGRBReviewDiscussionPost(createdBy uuid.UUID) *SystemIntakeGRBReviewDiscussionPost { diff --git a/pkg/models/system_intake_grb_reviewers.go b/pkg/models/system_intake_grb_reviewers.go index 573916f7e0..9ce6597708 100644 --- a/pkg/models/system_intake_grb_reviewers.go +++ b/pkg/models/system_intake_grb_reviewers.go @@ -9,36 +9,11 @@ import ( "github.com/cms-enterprise/easi-app/pkg/authentication" ) -type SIGRBReviewerRole string - -const ( - SIGRBRRCoChairCIO SIGRBReviewerRole = "CO_CHAIR_CIO" - SIGRBRRCoChairCFO SIGRBReviewerRole = "CO_CHAIR_CFO" - SIGRBRRCoChairHCA SIGRBReviewerRole = "CO_CHAIR_HCA" - SIGRBRRACA3021Rep SIGRBReviewerRole = "ACA_3021_REP" - SIGRBRRCCIIORep SIGRBReviewerRole = "CCIIO_REP" - SIGRBRRProgOpBDGChair SIGRBReviewerRole = "PROGRAM_OPERATIONS_BDG_CHAIR" - SIGRBRRCMCSRep SIGRBReviewerRole = "CMCS_REP" - SIGRBRRFedAdminBDGChair SIGRBReviewerRole = "FED_ADMIN_BDG_CHAIR" - SIGRBRRProgIntBDGChair SIGRBReviewerRole = "PROGRAM_INTEGRITY_BDG_CHAIR" - SIGRBRRQIORep SIGRBReviewerRole = "QIO_REP" - SIGRBRRSubjectMatterExpert SIGRBReviewerRole = "SUBJECT_MATTER_EXPERT" - SIGRBRROther SIGRBReviewerRole = "OTHER" -) - -type SIGRBReviewerVotingRole string - -const ( - SIGRBRVRVoting SIGRBReviewerVotingRole = "VOTING" - SIGRBRVRNonVoting SIGRBReviewerVotingRole = "NON_VOTING" - SIGRBRVRAlternate SIGRBReviewerVotingRole = "ALTERNATE" -) - -func (r SIGRBReviewerVotingRole) Humanize() (string, error) { - var grbVotingRoleTranslationsMap = map[SIGRBReviewerVotingRole]string{ - SIGRBRVRVoting: "Voting", - SIGRBRVRNonVoting: "Non-voting", - SIGRBRVRAlternate: "Alternate", +func (r SystemIntakeGRBReviewerVotingRole) Humanize() (string, error) { + var grbVotingRoleTranslationsMap = map[SystemIntakeGRBReviewerVotingRole]string{ + SystemIntakeGRBReviewerVotingRoleVoting: "Voting", + SystemIntakeGRBReviewerVotingRoleNonVoting: "Non-voting", + SystemIntakeGRBReviewerVotingRoleAlternate: "Alternate", } translation, ok := grbVotingRoleTranslationsMap[r] if !ok { @@ -47,20 +22,20 @@ func (r SIGRBReviewerVotingRole) Humanize() (string, error) { return translation, nil } -func (r SIGRBReviewerRole) Humanize() (string, error) { - var grbRoleTranslationsMap = map[SIGRBReviewerRole]string{ - SIGRBRRCoChairCIO: "Co-Chair - CIO", - SIGRBRRCoChairCFO: "CO-Chair - CFO", - SIGRBRRCoChairHCA: "CO-Chair - HCA", - SIGRBRRACA3021Rep: "ACA 3021 Rep", - SIGRBRRCCIIORep: "CCIIO Rep", - SIGRBRRProgOpBDGChair: "Program Operations BDG Chair", - SIGRBRRCMCSRep: "CMCS Rep", - SIGRBRRFedAdminBDGChair: "Fed Admin BDG Chair", - SIGRBRRProgIntBDGChair: "Program Integrity BDG Chair", - SIGRBRRQIORep: "QIO Rep", - SIGRBRRSubjectMatterExpert: "Subject Matter Expert (SME)", - SIGRBRROther: "Other", +func (r SystemIntakeGRBReviewerRole) Humanize() (string, error) { + var grbRoleTranslationsMap = map[SystemIntakeGRBReviewerRole]string{ + SystemIntakeGRBReviewerRoleCoChairCio: "Co-Chair - CIO", + SystemIntakeGRBReviewerRoleCoChairCfo: "CO-Chair - CFO", + SystemIntakeGRBReviewerRoleCoChairHca: "CO-Chair - HCA", + SystemIntakeGRBReviewerRoleAca3021Rep: "ACA 3021 Rep", + SystemIntakeGRBReviewerRoleCciioRep: "CCIIO Rep", + SystemIntakeGRBReviewerRoleProgramOperationsBdgChair: "Program Operations BDG Chair", + SystemIntakeGRBReviewerRoleCmcsRep: "CMCS Rep", + SystemIntakeGRBReviewerRoleFedAdminBdgChair: "Fed Admin BDG Chair", + SystemIntakeGRBReviewerRoleProgramIntegrityBdgChair: "Program Integrity BDG Chair", + SystemIntakeGRBReviewerRoleQioRep: "QIO Rep", + SystemIntakeGRBReviewerRoleSubjectMatterExpert: "Subject Matter Expert (SME)", + SystemIntakeGRBReviewerRoleOther: "Other", } translation, ok := grbRoleTranslationsMap[r] if !ok { @@ -73,9 +48,9 @@ func (r SIGRBReviewerRole) Humanize() (string, error) { type SystemIntakeGRBReviewer struct { BaseStructUser userIDRelation - SystemIntakeID uuid.UUID `json:"systemIntakeId" db:"system_intake_id"` - GRBVotingRole SIGRBReviewerVotingRole `json:"votingRole" db:"voting_role"` - GRBReviewerRole SIGRBReviewerRole `json:"grbRole" db:"grb_role"` + SystemIntakeID uuid.UUID `json:"systemIntakeId" db:"system_intake_id"` + GRBVotingRole SystemIntakeGRBReviewerVotingRole `json:"votingRole" db:"voting_role"` + GRBReviewerRole SystemIntakeGRBReviewerRole `json:"grbRole" db:"grb_role"` } func NewSystemIntakeGRBReviewer(userID uuid.UUID, createdBy uuid.UUID) *SystemIntakeGRBReviewer { From 06e519850319b4138137c9297050d71780589f28 Mon Sep 17 00:00:00 2001 From: ClayBenson94 Date: Fri, 13 Dec 2024 09:40:11 -0500 Subject: [PATCH 22/22] Remove need for type casting --- pkg/graph/resolvers/system_intake_grb_reviewer.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/graph/resolvers/system_intake_grb_reviewer.go b/pkg/graph/resolvers/system_intake_grb_reviewer.go index 273d84bf15..543f1bdc5a 100644 --- a/pkg/graph/resolvers/system_intake_grb_reviewer.go +++ b/pkg/graph/resolvers/system_intake_grb_reviewer.go @@ -67,8 +67,8 @@ func CreateSystemIntakeGRBReviewers( for _, acct := range accts { reviewerInput := reviewersByEUAMap[acct.Username] reviewer := models.NewSystemIntakeGRBReviewer(acct.ID, createdByID) - reviewer.GRBVotingRole = models.SystemIntakeGRBReviewerVotingRole(reviewerInput.VotingRole) - reviewer.GRBReviewerRole = models.SystemIntakeGRBReviewerRole(reviewerInput.GrbRole) + reviewer.GRBVotingRole = reviewerInput.VotingRole + reviewer.GRBReviewerRole = reviewerInput.GrbRole reviewer.SystemIntakeID = input.SystemIntakeID reviewersToCreate = append(reviewersToCreate, reviewer) }