diff --git a/EASI.postman_collection.json b/EASI.postman_collection.json index 4743cc5f63..1a753099bd 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,171 @@ } }, "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 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}" + } + }, + "url": { + "raw": "{{url}}", + "host": [ + "{{url}}" + ] + } + }, + "response": [] + }, + { + "name": "Create GRB Discussion", + "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\": \"{{systemIntakeID}}\",\r\n \"content\": \"

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

\"\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\": \"{{systemIntakeID}}\",\r\n \"content\": \"

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

\"\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\": \"{{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": { + "raw": "{{url}}", + "host": [ + "{{url}}" + ] + } + }, + "response": [] } ] }, @@ -1411,6 +1576,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": [ @@ -3824,9 +4022,10 @@ "listen": "test", "script": { "exec": [ - "" + "pm.collectionVariables.set(\"UserAccountID\", pm.response.json().data.userAccount.id)" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -3836,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": { @@ -4073,6 +4272,19 @@ { "key": "CedarAtoIds", "value": "" + }, + { + "key": "SystemIntakeGRBReviewerID", + "value": "" + }, + { + "key": "SystemIntakeGRBDiscussionID", + "value": "", + "type": "string" + }, + { + "key": "UserAccountID", + "value": "" } ] } diff --git a/cmd/devdata/main.go b/cmd/devdata/main.go index cf8c8ba164..23d68814c4 100644 --- a/cmd/devdata/main.go +++ b/cmd/devdata/main.go @@ -15,6 +15,8 @@ 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" @@ -80,10 +82,30 @@ 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) + 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. + // 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 +306,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 +350,59 @@ 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.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.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.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.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.TaggedHTML{ + RawContent: "Post C (No replies)", + Tags: nil, + }) + 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..c1e3871e43 --- /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.TaggedHTML, +) *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.TaggedHTML, +) *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/cmd/test_email_templates/main.go b/cmd/test_email_templates/main.go index a4ef0fd1ba..e8ba081bfd 100644 --- a/cmd/test_email_templates/main.go +++ b/cmd/test_email_templates/main.go @@ -629,6 +629,89 @@ 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", + Role: "Voting Member", + DiscussionContent: `

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

`, + Recipient: requesterEmail, + }, + ) + noErr(err) + + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail( + ctx, + email.SendGRBReviewDiscussionIndividualTaggedEmailInput{ + 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) + + // admin version + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail( + ctx, + email.SendGRBReviewDiscussionIndividualTaggedEmailInput{ + 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) + + // admin version + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail( + ctx, + email.SendGRBReviewDiscussionIndividualTaggedEmailInput{ + 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) + + err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail( + ctx, + email.SendGRBReviewDiscussionGroupTaggedEmailInput{ + 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) + + // admin version + err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail( + ctx, + email.SendGRBReviewDiscussionGroupTaggedEmailInput{ + 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) + err = client.SystemIntake.SendSystemIntakeAdminUploadDocEmail( ctx, email.SendSystemIntakeAdminUploadDocEmailInput{ 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/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/package.json b/package.json index 6dde27fbff..54c70c290e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,14 @@ "@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/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 +83,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 +188,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/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/email/email.go b/pkg/email/email.go index 8bef30574a..878412ee21 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, @@ -424,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() } @@ -469,6 +506,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 @@ -477,30 +517,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 new file mode 100644 index 0000000000..f9412da5d1 --- /dev/null +++ b/pkg/email/grb_review_discussion_group_tagged.go @@ -0,0 +1,97 @@ +package email + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "path" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/models" +) + +// 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 + Role string + DiscussionContent template.HTML + DiscussionID uuid.UUID + Recipients models.EmailNotificationRecipients +} + +// 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 models.EmailAddress + 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") + + data := GRBReviewDiscussionGroupTaggedBody{ + UserName: input.UserName, + GroupName: input.GroupName, + RequestName: input.RequestName, + DiscussionBoardType: "Internal GRB Discussion Board", + GRBReviewLink: sie.client.urlFromPath(grbReviewPath), + Role: input.Role, + DiscussionContent: input.DiscussionContent, + 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 + if err := sie.client.templates.grbReviewDiscussionGroupTagged.Execute(&b, data); err != nil { + return "", err + } + + return b.String(), nil +} + +// 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 := 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) + + if input.Recipients.ShouldNotifyITGovernance { + email = email.WithCCAddresses([]models.EmailAddress{sie.client.config.GRTEmail}) + } + + return sie.client.sender.Send( + ctx, + email, + ) +} 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..aeebb26d65 --- /dev/null +++ b/pkg/email/grb_review_discussion_group_tagged_test.go @@ -0,0 +1,211 @@ +package email + +import ( + "context" + "fmt" + "html/template" + "path" + + "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") + 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 := "Consumer" + discussionContent := template.HTML(`

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

`) + + 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())) + + ITGovInboxAddress := s.config.GRTEmail.String() + + recipient := models.NewEmailAddress("fake@fake.com") + recipients := models.EmailNotificationRecipients{ + RegularRecipientEmails: []models.EmailAddress{recipient}, + ShouldNotifyITGovernance: false, + ShouldNotifyITInvestment: false, + } + + input := SendGRBReviewDiscussionGroupTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + GroupName: groupName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipients: recipients, + } + + err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail(ctx, input) + s.NoError(err) + + expectedEmail := 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, + ) + + 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) + + }) + + s.Run("Recipient is correct", func() { + s.ElementsMatch(sender.bccAddresses, recipients.RegularRecipientEmails) + 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") + 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 := "Governance Admin Team" + discussionContent := template.HTML(`

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

`) + + 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())) + + recipient := models.NewEmailAddress("fake@fake.com") + recipients := models.EmailNotificationRecipients{ + RegularRecipientEmails: []models.EmailAddress{recipient}, + ShouldNotifyITGovernance: false, + ShouldNotifyITInvestment: false, + } + + input := SendGRBReviewDiscussionGroupTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + GroupName: groupName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipients: recipients, + } + + err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail(ctx, input) + s.NoError(err) + + expectedEmail := 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, + ) + + 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) + + }) + + s.Run("Recipient is correct", func() { + allRecipients := []models.EmailAddress{ + recipient, + } + s.ElementsMatch(sender.bccAddresses, allRecipients) + s.Empty(sender.toAddresses) + s.Empty(sender.ccAddresses) + }) + + 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..3371a5720f --- /dev/null +++ b/pkg/email/grb_review_discussion_individual_tagged.go @@ -0,0 +1,86 @@ +package email + +import ( + "bytes" + "context" + "errors" + "fmt" + "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 + Role string + DiscussionID uuid.UUID + DiscussionContent template.HTML + 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 models.EmailAddress + 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") + + data := GRBReviewDiscussionIndividualTaggedBody{ + UserName: input.UserName, + RequestName: input.RequestName, + DiscussionBoardType: "Internal GRB Discussion Board", + GRBReviewLink: sie.client.urlFromPath(grbReviewPath), + Role: input.Role, + DiscussionContent: input.DiscussionContent, + 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 + 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 := fmt.Sprintf("You were tagged in a GRB Review discussion for %s", input.RequestName) + + body, err := sie.grbReviewDiscussionIndividualTaggedBody(input) + if err != nil { + return err + } + + 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 new file mode 100644 index 0000000000..55f79ff7ec --- /dev/null +++ b/pkg/email/grb_review_discussion_individual_tagged_test.go @@ -0,0 +1,191 @@ +package email + +import ( + "context" + "fmt" + "html/template" + "path" + + "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") + 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!"

`) + + 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())) + + ITGovInboxAddress := s.config.GRTEmail.String() + + recipients := []models.EmailAddress{"fake@fake.com"} + + input := SendGRBReviewDiscussionIndividualTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipients: recipients, + } + + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail(ctx, input) + s.NoError(err) + + expectedEmail := 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, + ) + + 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.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) 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 := "Governance Admin Team" + discussionContent := template.HTML(`

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

`) + + 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())) + + recipients := []models.EmailAddress{"fake@fake.com"} + + input := SendGRBReviewDiscussionIndividualTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipients: recipients, + } + + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail(ctx, input) + s.NoError(err) + + expectedEmail := 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, + ) + + 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.bccAddresses[0], recipients[0]) + s.Empty(sender.ccAddresses) + s.Empty(sender.toAddresses) + }) + + 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..b8cc2954a0 --- /dev/null +++ b/pkg/email/grb_review_discussion_reply.go @@ -0,0 +1,85 @@ +package email + +import ( + "bytes" + "context" + "errors" + "fmt" + "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 + Role string + DiscussionID uuid.UUID + DiscussionContent template.HTML + Recipient 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 models.EmailAddress + 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") + + data := GRBReviewDiscussionReplyBody{ + UserName: input.UserName, + RequestName: input.RequestName, + DiscussionBoardType: "Internal GRB Discussion Board", + GRBReviewLink: sie.client.urlFromPath(grbReviewPath), + Role: input.Role, + DiscussionContent: input.DiscussionContent, + 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 + 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 := fmt.Sprintf("New reply to your discussion in the GRB Review for %s", input.RequestName) + + body, err := sie.grbReviewDiscussionReplyBody(input) + if err != nil { + return err + } + + return sie.client.sender.Send( + ctx, + NewEmail(). + 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 new file mode 100644 index 0000000000..fd08409387 --- /dev/null +++ b/pkg/email/grb_review_discussion_reply_test.go @@ -0,0 +1,198 @@ +package email + +import ( + "context" + "fmt" + "html/template" + "path" + + "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") + 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!"

`) + + 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())) + + ITGovInboxAddress := s.config.GRTEmail.String() + + recipient := models.NewEmailAddress("fake@fake.com") + + input := SendGRBReviewDiscussionReplyEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipient: recipient, + } + + err = client.SystemIntake.SendGRBReviewDiscussionReplyEmail(ctx, input) + s.NoError(err) + + 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 Reply

+
+

%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, + ) + + 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) + + }) + + 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) + }) +} + +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 := "Governance Admin Team" + discussionContent := template.HTML(`

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

`) + + 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())) + + recipient := models.NewEmailAddress("fake@fake.com") + + input := SendGRBReviewDiscussionReplyEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipient: recipient, + } + + err = client.SystemIntake.SendGRBReviewDiscussionReplyEmail(ctx, input) + s.NoError(err) + + 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 Reply

+
+

%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, + ) + + 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) + + }) + + 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..417a182855 100644 --- a/pkg/email/templates/easi_header.gohtml +++ b/pkg/email/templates/easi_header.gohtml @@ -25,14 +25,28 @@ .no-margin-top { margin-top: 0; } - .no-margin-bottom { - margin-bottom: 0; - } hr { border-style: solid; border-color: #bbb; border-radius: 2px; } + + .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 new file mode 100644 index 0000000000..503fe135b5 --- /dev/null +++ b/pkg/email/templates/grb_review_discussion_group_tagged.gohtml @@ -0,0 +1,30 @@ +{{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 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 new file mode 100644 index 0000000000..05e484b077 --- /dev/null +++ b/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml @@ -0,0 +1,30 @@ +{{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 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 new file mode 100644 index 0000000000..3dd18c1384 --- /dev/null +++ b/pkg/email/templates/grb_review_discussion_reply.gohtml @@ -0,0 +1,30 @@ +{{template "easi_header.gohtml"}} + +

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

+ +

View this request in EASi

+ +
+ +

Discussion Reply

+ +
+

{{.UserName}}

+

{{.Role}}

+ +
+
{{.DiscussionContent}}
+ +
+

+ + Reply in EASi + +

+ +
+{{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/generated/generated.go b/pkg/graph/generated/generated.go index 826d52420b..26f3237c9e 100644 --- a/pkg/graph/generated/generated.go +++ b/pkg/graph/generated/generated.go @@ -563,6 +563,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 +698,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 +842,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 +1233,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 +1367,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) @@ -4124,6 +4147,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 +5475,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 +6196,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 +7836,8 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputUpdateTRBRequestFormInput, ec.unmarshalInputUpdateTRBRequestFundingSourcesInput, ec.unmarshalInputUpdateTRBRequestTRBLeadInput, + ec.unmarshalInputcreateSystemIntakeGRBDiscussionPostInput, + ec.unmarshalInputcreateSystemIntakeGRBDiscussionReplyInput, ) first := true @@ -8595,6 +8728,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 +9167,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: TaggedHTML! +} + +input createSystemIntakeGRBDiscussionReplyInput { + initialPostID: UUID! + content: TaggedHTML! +} + """ Input data used to update the admin lead assigned to a system IT governance request @@ -9498,6 +9663,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 """ @@ -10323,6 +10496,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! @@ -10473,6 +10649,10 @@ HTML are represented using as strings,

Notification email 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) _SystemIntakeDocumentType(ctx context.Context, sel ast.SelectionSet, obj *models.SystemIntakeDocumentType) graphql.Marshaler { - fields := graphql.CollectFields(ec.OperationContext, sel, systemIntakeDocumentTypeImplementors) +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("SystemIntakeDocumentType") - case "commonType": - out.Values[i] = ec._SystemIntakeDocumentType_commonType(ctx, field, obj) + out.Values[i] = graphql.MarshalString("SystemIntakeGRBReviewDiscussionPost") + case "id": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_id(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } - 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 - } + case "content": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_content(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "votingRole": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_votingRole(ctx, field, obj) + case "grbRole": + 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 { + atomic.AddUint32(&out.Invalids, 1) + } + case "createdByUserAccount": + field := field - atomic.AddInt32(&ec.deferred, int32(len(deferred))) + 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._SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } - for label, dfs := range deferred { - ec.processDeferredGroup(graphql.DeferredGroup{ - Label: label, - Path: graphql.GetPath(ctx), - FieldSet: dfs, - Context: ctx, - }) - } + 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) + }) - return out -} + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } -var systemIntakeFundingSourceImplementors = []string{"SystemIntakeFundingSource"} + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "createdAt": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_createdAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "modifiedByUserAccount": + field := field -func (ec *executionContext) _SystemIntakeFundingSource(ctx context.Context, sel ast.SelectionSet, obj *models.SystemIntakeFundingSource) graphql.Marshaler { - fields := graphql.CollectFields(ec.OperationContext, sel, systemIntakeFundingSourceImplementors) + 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 + } - 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++ + 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 } - 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 +76206,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) } @@ -76205,6 +77618,16 @@ func (ec *executionContext) marshalNTRBTaskStatuses2ᚖgithubᚗcomᚋcmsᚑente return ec._TRBTaskStatuses(ctx, sel, v) } +func (ec *executionContext) unmarshalNTaggedHTML2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐTaggedHTML(ctx context.Context, v interface{}) (models.TaggedHTML, error) { + var res models.TaggedHTML + err := res.UnmarshalGQLContext(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNTaggedHTML2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐTaggedHTML(ctx context.Context, sel ast.SelectionSet, v models.TaggedHTML) graphql.Marshaler { + return graphql.WrapContextMarshaler(ctx, v) +} + func (ec *executionContext) unmarshalNTime2timeᚐTime(ctx context.Context, v interface{}) (time.Time, error) { res, err := graphql.UnmarshalTime(v) return res, graphql.ErrorOnPath(ctx, err) @@ -76719,6 +78142,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 +78830,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/resolvers/resolver_test.go b/pkg/graph/resolvers/resolver_test.go index cb0dbcc983..3f621151fa 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) @@ -156,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)), @@ -168,21 +172,26 @@ 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 } // 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 @@ -263,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_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..cd75d32026 --- /dev/null +++ b/pkg/graph/resolvers/system_intake_grb_discussions.go @@ -0,0 +1,376 @@ +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" + "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) + + intakeID := input.SystemIntakeID + + // 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 principalAsGRBReviewer == nil && !isAdmin { + return nil, errors.New("user not authorized to create discussion post") + } + + post := models.NewSystemIntakeGRBReviewDiscussionPost(principal.Account().ID) + post.Content = input.Content.RawContent + post.SystemIntakeID = intakeID + 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 result, nil + }) +} + +// 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 := 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") + } + + // 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 + if principalGRBReviewer != nil { + post.VotingRole = &principalGRBReviewer.GRBVotingRole + post.GRBRole = &principalGRBReviewer.GRBReviewerRole + } + + 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 + } + + // 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 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 + }) + + // 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 + } + // 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 + } + + // 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_discussions_test.go b/pkg/graph/resolvers/system_intake_grb_discussions_test.go new file mode 100644 index 0000000000..db157a889e --- /dev/null +++ b/pkg/graph/resolvers/system_intake_grb_discussions_test.go @@ -0,0 +1,620 @@ +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" +) + +func (s *ResolverSuite) TestSystemIntakeGRBDiscussions() { + store := s.testConfigs.Store + + s.Run("create and retrieve initial discussion", func() { + emailClient, _ := NewEmailClient() + + intake := s.createNewIntake() + ctx, _ := s.getTestContextWithPrincipal("ABCD", true) + post := s.createGRBDiscussion( + ctx, + emailClient, + intake.ID, + "

banana

", + ) + + // 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, _ := s.getTestContextWithPrincipal("ABCD", true) + s.createGRBDiscussion( + ctx, + emailClient, + intake.ID, + "

banana

", + ) + + }) + + s.Run("create GRB discussion and add to intake as reviewer", func() { + emailClient, _ := NewEmailClient() + + intake, _ := s.createIntakeAndAddReviewersByEUAs("ABCD") + + ctx, _ := s.getTestContextWithPrincipal("ABCD", false) + s.createGRBDiscussion( + ctx, + emailClient, + intake.ID, + "

banana

", + ) + }) + + 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) + }) + + s.Run("tagged reviewers should receive an email", func() { + emailClient, sender := NewEmailClient() + + intake, _ := s.createIntakeAndAddReviewersByEUAs("ABCD", "BTMN", "USR2") + + 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.createGRBDiscussion( + ctx, + emailClient, + 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.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 := s.createNewIntake() + + ctx, princ := s.getTestContextWithPrincipal("USR1", true) + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "

this is a discussion

") + + replyPost := s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + "

banana

", + ) + + // 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(reply.ID, replyPost.ID) + 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, _ := s.createIntakeAndAddReviewersByEUAs("BTMN") + + ctx, discAuthor := s.getTestContextWithPrincipal("USR1", true) + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "

this is a discussion

") + + ctx, replyAuthor := s.getTestContextWithPrincipal("BTMN", false) + replyPost := s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + "

banana

", + ) + + // 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(reply.ID, replyPost.ID) + 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 := s.createNewIntake() + + ctx, discAuthor := s.getTestContextWithPrincipal("USR1", true) + discussionPost := s.createGRBDiscussion(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, _ := s.createIntakeAndAddReviewersByEUAs("BTMN", "ABCD") + + ctx, discAuthor := s.getTestContextWithPrincipal("USR1", true) + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "

this is a discussion

") + + ctx, reply1Author := s.getTestContextWithPrincipal("BTMN", false) + reply1Post := s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + "

banana

", + ) + + ctx, reply2Author := s.getTestContextWithPrincipal("ABCD", false) + reply2Post := s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + "

tangerine

", + ) + + // 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(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) + s.Equal(reply2.Content, models.HTML("

tangerine

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

this is a discussion

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

this is a second discussion

") + + // reply to the first discussion + ctx, reply1Author := s.getTestContextWithPrincipal("BTMN", false) + reply1Post := s.createGRBDiscussionReply( + ctx, + emailClient, + discussion1Post, + "

banana

", + ) + + // reply to the second discussion + ctx, reply2Author := s.getTestContextWithPrincipal("ABCD", false) + reply2Post := s.createGRBDiscussionReply( + ctx, + emailClient, + discussion2Post, + "

tangerine

", + ) + + // 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(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.go b/pkg/graph/resolvers/system_intake_grb_reviewer.go index d85f3b5baa..543f1bdc5a 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, @@ -55,8 +67,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 = reviewerInput.VotingRole + reviewer.GRBReviewerRole = reviewerInput.GrbRole reviewer.SystemIntakeID = input.SystemIntakeID reviewersToCreate = append(reviewersToCreate, reviewer) } @@ -144,8 +156,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 @@ -223,3 +235,17 @@ func StartGRBReview( return helpers.PointerTo("started GRB review"), nil }) } + +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 { + return nil, err + } + for _, reviewer := range grbReviewers { + if reviewer != nil && reviewer.UserID == principalUserAcctID { + return reviewer, nil + } + } + return nil, nil +} diff --git a/pkg/graph/resolvers/system_intake_grb_reviewer_test.go b/pkg/graph/resolvers/system_intake_grb_reviewer_test.go index a1aaabce6d..c37764246e 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, @@ -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) }) @@ -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{ @@ -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.graphql b/pkg/graph/schema.graphql index 64c908337c..fd854f1f69 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: TaggedHTML! +} + +input createSystemIntakeGRBDiscussionReplyInput { + initialPostID: UUID! + content: TaggedHTML! +} + """ Input data used to update the admin lead assigned to a system IT governance request @@ -1694,6 +1726,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 """ @@ -2519,6 +2559,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! @@ -2669,6 +2712,10 @@ HTML are represented using as strings,

Notification 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 +} + +func (th TaggedHTML) ToTemplate() template.HTML { + sanitized := sanitization.SanitizeTaggedHTML(th.RawContent) + return template.HTML(sanitized) //nolint +} 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..2673af6e93 --- /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", "data-label").OnElements("span") + return policy +} 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..c8367fe014 --- /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..6ddf475ab5 --- /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..c5c424e371 --- /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), + }) +} 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/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/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), }) } diff --git a/scripts/dev b/scripts/dev index 9afb8d3359..7ec499af23 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(' ')}" @@ -360,6 +360,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/components/MentionTextArea/MentionList.tsx b/src/components/MentionTextArea/MentionList.tsx new file mode 100644 index 0000000000..4fde7ad184 --- /dev/null +++ b/src/components/MentionTextArea/MentionList.tsx @@ -0,0 +1,123 @@ +/* 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 { TagType } from 'gql/gen/graphql'; + +import Spinner from 'components/Spinner'; +import { + MentionListOnKeyDown, + MentionSuggestionProps +} from 'types/discussions'; + +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' }); +}; + +/** Renders the list of suggestions within `MentionTextArea` */ +const MentionList = forwardRef( + (props, ref) => { + const { t } = useTranslation('general'); + + const [selectedIndex, setSelectedIndex] = useState(0); + + /** Sets the selected mention within the editor props */ + const selectItem = (index: number) => { + const item = props.items[index]; + + 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 : '' + }); + } + }; + + 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; + } + })); + + 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 new file mode 100644 index 0000000000..92b1570e5f --- /dev/null +++ b/src/components/MentionTextArea/__snapshots__/index.test.tsx.snap @@ -0,0 +1,45 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`MentionTextArea component > renders the component to view text 1`] = ` + +
+
+

+ 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. +

+
+
+
+`; + +exports[`MentionTextArea component > renders the editable text area component 1`] = ` + +
+
+ This is the text! +
+
+
+`; diff --git a/src/components/MentionTextArea/index.scss b/src/components/MentionTextArea/index.scss new file mode 100644 index 0000000000..eefd869785 --- /dev/null +++ b/src/components/MentionTextArea/index.scss @@ -0,0 +1,116 @@ +@use 'uswds-core' as *; + +/* Basic editor styles */ +.tiptap { + p { + margin: 0px; + } + + &.ProseMirror { + font-family: inherit; + font-size: inherit; + line-height: inherit; + height: inherit; + } + + &__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; + } + + &.usa-textarea { + padding: 0; + + div[contenteditable] { + padding: .5rem; + } + } + } +} + +[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.test.tsx b/src/components/MentionTextArea/index.test.tsx new file mode 100644 index 0000000000..231c115d5d --- /dev/null +++ b/src/components/MentionTextArea/index.test.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import MentionTextArea from '.'; + +const 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.'; + +describe('MentionTextArea component', () => { + it('renders the component to view text', () => { + const { asFragment } = render( + + ); + + expect(asFragment()).toMatchSnapshot(); + + expect(screen.getByText(content)).toBeInTheDocument(); + + // Hide "Read more" button when `truncateText` is false + expect(screen.queryByRole('button', { name: 'Read more' })).toBeNull(); + }); + + it('renders the editable text area component', () => { + const { asFragment } = render( + + ); + + const editor = screen.getByRole('textbox'); + const text = 'This is the text!'; + + userEvent.type(editor, text); + + expect(editor).toHaveTextContent(text); + + expect(asFragment()).toMatchSnapshot(); + }); + + it('truncates text with read more/less button', async () => { + render( + + ); + + const truncatedText = content.slice(0, 275); + + expect(screen.getByText(`${truncatedText} ...`)).toBeInTheDocument(); + + const readMoreButton = screen.getByRole('button', { name: 'Read more' }); + expect(readMoreButton).toBeInTheDocument(); + userEvent.click(readMoreButton); + + const readLessButton = await screen.findByRole('button', { + name: 'Read less' + }); + expect(readLessButton).toBeInTheDocument(); + + expect(screen.getByText(content)).toBeInTheDocument(); + }); + + it('handles truncated text shorter than character limit', () => { + const shortenedString = content.slice(0, 100); + + render( + + ); + + expect(screen.queryByRole('button', { name: 'Read more' })).toBeNull(); + }); +}); diff --git a/src/components/MentionTextArea/index.tsx b/src/components/MentionTextArea/index.tsx new file mode 100644 index 0000000000..61e86c2573 --- /dev/null +++ b/src/components/MentionTextArea/index.tsx @@ -0,0 +1,239 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import Document from '@tiptap/extension-document'; +import Mention, { MentionOptions } from '@tiptap/extension-mention'; +import Paragraph from '@tiptap/extension-paragraph'; +import Text from '@tiptap/extension-text'; +import { + EditorContent, + EditorEvents, + Node, + NodeViewProps, + NodeViewWrapper, + ReactNodeViewRenderer, + useEditor +} from '@tiptap/react'; +import { Icon } from '@trussworks/react-uswds'; +import classNames from 'classnames'; + +import Alert from 'components/shared/Alert'; +import IconButton from 'components/shared/IconButton'; +import { MentionAttributes, MentionSuggestion } from 'types/discussions'; + +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 +const MentionComponent = ({ node }: NodeViewProps) => { + // Get attributes of selected mention + const { label } = node.attrs; + + if (!label) return null; + + return ( + + {`@${label}`} + + ); +}; + +/** + * 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() { + return { + ...this.parent?.(), + 'data-id-db': { + default: '' + }, + 'tag-type': { + default: '' + } + }; + }, + addNodeView() { + return ReactNodeViewRenderer(MentionComponent); + } +}); + +type MentionTextAreaProps = { + id: string; + setFieldValue?: (value: string) => void; + mentionSuggestions?: MentionSuggestion[]; + editable?: boolean; + disabled?: boolean; + initialContent?: string; + /** Truncate text with read more/less button for non-editable text */ + truncateText?: boolean; + className?: string; +}; + +/** + * Rich text area component with functionality to tag users or teams + */ +const MentionTextArea = React.forwardRef( + ( + { + id, + setFieldValue, + mentionSuggestions, + editable = false, + disabled, + initialContent, + truncateText, + className + }, + ref + ) => { + const { t } = useTranslation('discussions'); + + const [textExpanded, setTextExpanded] = useState(false); + + /** Alert shown below field when tagging user or user group */ + const [tagAlert, setTagAlert] = useState(false); + + /** Mock users array for testing until tagging functionality is implemented */ + 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 */ + const truncatedTextCharLimit = 275; + + const textIsTruncated = useMemo(() => { + if (editable || !initialContent) return false; + + return truncateText && initialContent.length > truncatedTextCharLimit; + }, [initialContent, truncateText, truncatedTextCharLimit, editable]); + + /** Editor content */ + const content = useMemo(() => { + // If no initial content, set to empty string + if (!initialContent) return ''; + + // If text is not truncated or truncated and expanded, return `initialContent` + if (!textIsTruncated || textExpanded) return initialContent; + + // Return truncated text with ellipses + return `${initialContent.slice(0, truncatedTextCharLimit)} ...`; + }, [textIsTruncated, truncatedTextCharLimit, textExpanded, initialContent]); + + /** Tiptap editor instance */ + const editor = useEditor( + { + editable: editable && !disabled, + editorProps: { + attributes: { + id, + role: 'textbox', + 'aria-label': t('Rich text area') + } + }, + extensions: [ + Document, + Paragraph, + Text, + CustomMention.configure({ + HTMLAttributes: { + class: 'mention' + }, + suggestion: { + ...suggestion, + items: fetchUsers + } + }) + ], + onUpdate: ({ editor: input }) => { + const inputContent = input?.getHTML(); + const inputText = input?.getText(); + + if (setFieldValue) { + // Prevents editor from setting value to '

' when user deletes all text + if (inputText === '') { + setFieldValue(''); + return; + } + + setFieldValue(inputContent); + } + }, + // Sets an alert if a mention is selected, and users/teams will be emailed + onSelectionUpdate: ({ + editor: input + }: EditorEvents['selectionUpdate']) => { + setTagAlert(!!getMentions(input?.getJSON()).length); + }, + content + }, + [textExpanded] + ); + + /** Clear editor content when field is reset */ + useEffect(() => { + if (editable) { + if ( + !initialContent || + // Check if value is empty string and editor is not already reset + (initialContent.length === 0 && initialContent !== editor?.getText()) + ) { + editor?.commands.clearContent(); + } + } + }, [editor, initialContent, editable]); + + return ( + <> + + + { + // Read more/less button for truncated text + textIsTruncated && ( + : } + type="button" + onClick={() => setTextExpanded(!textExpanded)} + iconPosition="after" + className="margin-bottom-205" + unstyled + > + {textExpanded ? t('general:readLess') : t('general:readMore')} + + ) + } + + {tagAlert && editable && ( + + {t('general.alerts.saveDiscussion')} + + )} + + ); + } +); + +export default MentionTextArea; diff --git a/src/components/MentionTextArea/suggestion.ts b/src/components/MentionTextArea/suggestion.ts new file mode 100644 index 0000000000..970a841120 --- /dev/null +++ b/src/components/MentionTextArea/suggestion.ts @@ -0,0 +1,142 @@ +import { ReactRenderer } from '@tiptap/react'; +import { SuggestionOptions } from '@tiptap/suggestion'; +import tippy, { GetReferenceClientRect, Instance } from 'tippy.js'; + +import { + MentionAttributes, + MentionListOnKeyDown, + MentionSuggestion, + MentionSuggestionProps +} from 'types/discussions'; + +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 = ({ + 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 + ); +}; + +const suggestion: Omit< + SuggestionOptions, + 'editor' +> = { + allowSpaces: true, + render: () => { + 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 => { + if (!props.clientRect) { + return; + } + + reactRenderer = new ReactRenderer(SuggestionLoading, { + props, + editor: props.editor + }); + + [spinner] = tippy('body', { + getReferenceClientRect: getClientRect(props), + appendTo: props.editor.options.element, + content: reactRenderer.element, + showOnCreate: true, + interactive: false, + trigger: 'manual', + placement: 'bottom-start' + }); + }, + + // Render any available suggestions when mention trigger is first called - @ + onStart: props => { + if (!props.clientRect) { + return; + } + + spinner.hide?.(); + + reactRenderer = new ReactRenderer(MentionList, { + props, + editor: props.editor + }); + + [popup] = tippy('body', { + getReferenceClientRect: getClientRect(props), + appendTo: props.editor.options.element, + 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 => { + reactRenderer.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup.setProps?.({ + getReferenceClientRect: getClientRect(props) + }); + + spinner.setProps?.({ + getReferenceClientRect: getClientRect(props) + }); + + spinner.hide?.(); + + popup.show?.(); + }, + + // If a valid character key, render the spinner until onUpdate gets called to rerender updated list + onKeyDown: props => { + if (props.event.key === 'Escape') { + popup.hide?.(); + spinner.hide?.(); + + return true; + } + + if (props.event.key.length === 1 || props.event.key === 'Backspace') { + popup.hide?.(); + spinner.show?.(); + } + + return !!reactRenderer?.ref && reactRenderer.ref.onKeyDown(props); + }, + + onExit() { + popup.destroy?.(); + spinner.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..e3cf2973ab --- /dev/null +++ b/src/components/MentionTextArea/util.tsx @@ -0,0 +1,79 @@ +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/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/data/mock/discussions.ts b/src/data/mock/discussions.ts new file mode 100644 index 0000000000..a9a014e554 --- /dev/null +++ b/src/data/mock/discussions.ts @@ -0,0 +1,141 @@ +import { + SystemIntakeGRBReviewDiscussionFragment, + SystemIntakeGRBReviewerRole, + SystemIntakeGRBReviewerVotingRole +} from 'gql/gen/graphql'; + +import { systemIntake } from './systemIntake'; +import users from './users'; + +export 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 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..b0b2ea5457 --- /dev/null +++ b/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionPost.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +import SystemIntakeGRBReviewDiscussionPost from './SystemIntakeGRBReviewDiscussion'; + +export default gql(/* GraphQL */ ` + ${SystemIntakeGRBReviewDiscussionPost} + mutation CreateSystemIntakeGRBDiscussionPost( + $input: createSystemIntakeGRBDiscussionPostInput! + ) { + createSystemIntakeGRBDiscussionPost(input: $input) { + ...SystemIntakeGRBReviewDiscussionPost + } + } +`); diff --git a/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionReply.ts b/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionReply.ts new file mode 100644 index 0000000000..45d2c2c0a3 --- /dev/null +++ b/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionReply.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +import SystemIntakeGRBReviewDiscussionPost from './SystemIntakeGRBReviewDiscussion'; + +export default gql(/* GraphQL */ ` + ${SystemIntakeGRBReviewDiscussionPost} + mutation CreateSystemIntakeGRBDiscussionReply( + $input: createSystemIntakeGRBDiscussionReplyInput! + ) { + createSystemIntakeGRBDiscussionReply(input: $input) { + ...SystemIntakeGRBReviewDiscussionPost + } + } +`); diff --git a/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBDiscussions.ts b/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBDiscussions.ts new file mode 100644 index 0000000000..f446a2cfe3 --- /dev/null +++ b/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBDiscussions.ts @@ -0,0 +1,15 @@ +import { gql } from '@apollo/client'; + +import SystemIntakeGRBReviewDiscussion from './SystemIntakeGRBReviewDiscussion'; + +export default gql(/* GraphQL */ ` + ${SystemIntakeGRBReviewDiscussion} + query GetSystemIntakeGRBDiscussions($id: UUID!) { + systemIntake(id: $id) { + id + grbDiscussions { + ...SystemIntakeGRBReviewDiscussion + } + } + } +`); diff --git a/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReviewers.ts b/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReviewers.ts index b27b4c3ac0..9f0a9461f8 100644 --- a/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReviewers.ts +++ b/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReviewers.ts @@ -2,7 +2,7 @@ import { gql } from '@apollo/client'; import SystemIntakeGRBReviewer from './SystemIntakeGRBReviewer'; -const GetSystemIntakeGRBReviewers = gql(/* GraphQL */ ` +export default gql(/* GraphQL */ ` ${SystemIntakeGRBReviewer} query GetSystemIntakeGRBReviewers($id: UUID!) { systemIntake(id: $id) { @@ -14,5 +14,3 @@ const GetSystemIntakeGRBReviewers = gql(/* GraphQL */ ` } } `); - -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..237f2bb2d0 --- /dev/null +++ b/src/gql/apolloGQL/grbReview/SystemIntakeGRBReviewDiscussion.ts @@ -0,0 +1,28 @@ +import { gql } from '@apollo/client'; + +export 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 20b6d9e795..da22b128bb 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 */ @@ -965,6 +967,8 @@ export type Mutation = { createSystemIntakeActionUpdateLCID?: Maybe; createSystemIntakeContact?: Maybe; createSystemIntakeDocument?: Maybe; + createSystemIntakeGRBDiscussionPost?: Maybe; + createSystemIntakeGRBDiscussionReply?: Maybe; createSystemIntakeGRBReviewers?: Maybe; createSystemIntakeNote?: Maybe; createTRBAdminNoteConsultSession: TRBAdminNote; @@ -1142,6 +1146,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 +1860,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 +2226,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'; @@ -2973,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 @@ -3183,6 +3226,30 @@ export type UserInfo = { lastName: Scalars['String']['output']; }; +export type CreateSystemIntakeGRBDiscussionPostInput = { + content: Scalars['TaggedHTML']['input']; + systemIntakeID: Scalars['UUID']['input']; +}; + +export type CreateSystemIntakeGRBDiscussionReplyInput = { + content: Scalars['TaggedHTML']['input']; + initialPostID: Scalars['UUID']['input']; +}; + +export type CreateSystemIntakeGRBDiscussionPostMutationVariables = Exact<{ + input: CreateSystemIntakeGRBDiscussionPostInput; +}>; + + +export type CreateSystemIntakeGRBDiscussionPostMutation = { __typename: 'Mutation', createSystemIntakeGRBDiscussionPost?: { __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 CreateSystemIntakeGRBDiscussionReplyMutationVariables = Exact<{ + input: CreateSystemIntakeGRBDiscussionReplyInput; +}>; + + +export type CreateSystemIntakeGRBDiscussionReplyMutation = { __typename: 'Mutation', createSystemIntakeGRBDiscussionReply?: { __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 CreateSystemIntakeGRBReviewersMutationVariables = Exact<{ input: CreateSystemIntakeGRBReviewersInput; }>; @@ -3204,6 +3271,13 @@ 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 GetSystemIntakeGRBDiscussionsQueryVariables = Exact<{ + id: Scalars['UUID']['input']; +}>; + + +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 GetSystemIntakeGRBReviewersQueryVariables = Exact<{ id: Scalars['UUID']['input']; }>; @@ -3225,6 +3299,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<{ @@ -3429,6 +3507,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 @@ -3523,6 +3625,72 @@ export const TRBGuidanceLetterFragmentDoc = gql` modifiedAt } ${TRBGuidanceLetterInsightFragmentDoc}`; +export const CreateSystemIntakeGRBDiscussionPostDocument = gql` + mutation CreateSystemIntakeGRBDiscussionPost($input: createSystemIntakeGRBDiscussionPostInput!) { + createSystemIntakeGRBDiscussionPost(input: $input) { + ...SystemIntakeGRBReviewDiscussionPost + } +} + ${SystemIntakeGRBReviewDiscussionPostFragmentDoc}`; +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) { + ...SystemIntakeGRBReviewDiscussionPost + } +} + ${SystemIntakeGRBReviewDiscussionPostFragmentDoc}`; +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) { @@ -3642,6 +3810,49 @@ export type GetGRBReviewersComparisonsQueryHookResult = ReturnType; export type GetGRBReviewersComparisonsSuspenseQueryHookResult = ReturnType; export type GetGRBReviewersComparisonsQueryResult = Apollo.QueryResult; +export const GetSystemIntakeGRBDiscussionsDocument = gql` + query GetSystemIntakeGRBDiscussions($id: UUID!) { + systemIntake(id: $id) { + id + grbDiscussions { + ...SystemIntakeGRBReviewDiscussion + } + } +} + ${SystemIntakeGRBReviewDiscussionFragmentDoc}`; + +/** + * __useGetSystemIntakeGRBDiscussionsQuery__ + * + * To run a query within a React component, call `useGetSystemIntakeGRBDiscussionsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetSystemIntakeGRBDiscussionsQuery` 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 } = useGetSystemIntakeGRBDiscussionsQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useGetSystemIntakeGRBDiscussionsQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: GetSystemIntakeGRBDiscussionsQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetSystemIntakeGRBDiscussionsDocument, options); + } +export function useGetSystemIntakeGRBDiscussionsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetSystemIntakeGRBDiscussionsDocument, options); + } +export function useGetSystemIntakeGRBDiscussionsSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) { + const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(GetSystemIntakeGRBDiscussionsDocument, options); + } +export type GetSystemIntakeGRBDiscussionsQueryHookResult = ReturnType; +export type GetSystemIntakeGRBDiscussionsLazyQueryHookResult = ReturnType; +export type GetSystemIntakeGRBDiscussionsSuspenseQueryHookResult = ReturnType; +export type GetSystemIntakeGRBDiscussionsQueryResult = Apollo.QueryResult; export const GetSystemIntakeGRBReviewersDocument = gql` query GetSystemIntakeGRBReviewers($id: UUID!) { systemIntake(id: $id) { @@ -4808,6 +5019,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; @@ -4815,9 +5028,12 @@ 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":"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 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":"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 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 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 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; 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/i18n/en-US/discussions.ts b/src/i18n/en-US/discussions.ts new file mode 100644 index 0000000000..d282b5f921 --- /dev/null +++ b/src/i18n/en-US/discussions.ts @@ -0,0 +1,98 @@ +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', + discussion: 'Discussion', + mostRecentActivity: 'Most recent activity', + 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? + 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}}', + hideReplies: 'Hide replies', + showReplies: 'Show replies', + + 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: { + 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: + '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.' + }, + + startDiscussion: { + heading: 'Start a discussion', + 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.', + }, + + 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: { + 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. 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' + ] + } + }, + + // Board Specific Translations + governanceReviewBoard: { + 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: + // '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; diff --git a/src/i18n/en-US/general.ts b/src/i18n/en-US/general.ts index 23b8341a2d..ca365b9012 100644 --- a/src/i18n/en-US/general.ts +++ b/src/i18n/en-US/general.ts @@ -11,8 +11,11 @@ const general = { remove: 'Remove', pageLoading: 'Loading the page', loadingResults: 'Loading results', + noResults: 'No results', noInfoToDisplay: 'No information to display', - noDataAvailable: 'No data available' + noDataAvailable: 'No data available', + readMore: 'Read more', + readLess: 'Read less' }; export default general; 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, 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/types/discussions.ts b/src/types/discussions.ts new file mode 100644 index 0000000000..0e939d5304 --- /dev/null +++ b/src/types/discussions.ts @@ -0,0 +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/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/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..3659e6ff71 --- /dev/null +++ b/src/views/DiscussionBoard/Discussion.test.tsx @@ -0,0 +1,158 @@ +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 { + 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..dd086c9180 --- /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, MentionSuggestion } 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; + mentionSuggestions: MentionSuggestion[]; +}; + +/** + * Single discussion post view + * + * Displays discussion, replies, and form to reply to discussion post + */ +const Discussion = ({ + discussion, + closeModal, + setDiscussionAlert, + mentionSuggestions +}: 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 new file mode 100644 index 0000000000..91cec9413a --- /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/StartDiscussion.tsx b/src/views/DiscussionBoard/StartDiscussion.tsx new file mode 100644 index 0000000000..50fffeb212 --- /dev/null +++ b/src/views/DiscussionBoard/StartDiscussion.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { DiscussionAlert, MentionSuggestion } from 'types/discussions'; + +import DiscussionForm from './components/DiscussionForm'; + +type StartDiscussionProps = { + systemIntakeID: string; + closeModal: () => void; + setDiscussionAlert: (discussionAlert: DiscussionAlert) => void; + mentionSuggestions: MentionSuggestion[]; +}; + +/** + * Form to start new discussion post + */ +const StartDiscussion = ({ + systemIntakeID, + closeModal, + setDiscussionAlert, + mentionSuggestions +}: 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..53283f709b --- /dev/null +++ b/src/views/DiscussionBoard/ViewDiscussions.test.tsx @@ -0,0 +1,127 @@ +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 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..60eb065421 --- /dev/null +++ b/src/views/DiscussionBoard/ViewDiscussions.tsx @@ -0,0 +1,123 @@ +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 useDiscussionParams from 'hooks/useDiscussionParams'; + +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 { pushDiscussionQuery } = useDiscussionParams(); + + 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')}

    + { + pushDiscussionQuery({ discussionMode: 'start' }); + }} + 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..2627727998 --- /dev/null +++ b/src/views/DiscussionBoard/components/DiscussionForm.tsx @@ -0,0 +1,193 @@ +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 { + GetSystemIntakeGRBDiscussionsDocument, + 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 useDiscussionParams from 'hooks/useDiscussionParams'; +import { DiscussionAlert, MentionSuggestion } from 'types/discussions'; +import discussionSchema from 'validations/discussionSchema'; + +type DiscussionContent = { + content: string; +}; + +interface DiscussionFormProps { + setDiscussionAlert: (discussionAlert: DiscussionAlert) => void; + closeModal: () => void; + mentionSuggestions: MentionSuggestion[]; +} + +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, + mentionSuggestions, + ...mutationProps +}: DiscussionProps | ReplyProps) => { + const { t } = useTranslation('discussions'); + + const [mutateDiscussion] = useCreateSystemIntakeGRBDiscussionPostMutation({ + refetchQueries: [GetSystemIntakeGRBDiscussionsDocument] + }); + + const [mutateReply] = useCreateSystemIntakeGRBDiscussionReplyMutation({ + refetchQueries: [GetSystemIntakeGRBDiscussionsDocument] + }); + + const { + control, + handleSubmit, + reset, + formState: { isValid, errors } + } = useEasiForm({ + resolver: yupResolver(discussionSchema), + defaultValues: { + content: '' + } + }); + + const { pushDiscussionQuery } = useDiscussionParams(); + + 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' + }); + }) + .finally(() => { + pushDiscussionQuery({ discussionMode: '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')} + + + + + ( + + )} + /> + + + + + + +
    + ); +}; + +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..33752b6bab --- /dev/null +++ b/src/views/DiscussionBoard/components/DiscussionPost/index.scss @@ -0,0 +1,12 @@ +.easi-discussion-post { + &__header { + justify-content: space-between; + } + + .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/components/DiscussionPost/index.test.tsx b/src/views/DiscussionBoard/components/DiscussionPost/index.test.tsx new file mode 100644 index 0000000000..5ac3df4a66 --- /dev/null +++ b/src/views/DiscussionBoard/components/DiscussionPost/index.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +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/components/DiscussionPost/index.tsx b/src/views/DiscussionBoard/components/DiscussionPost/index.tsx new file mode 100644 index 0000000000..7803b4ddad --- /dev/null +++ b/src/views/DiscussionBoard/components/DiscussionPost/index.tsx @@ -0,0 +1,139 @@ +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 MentionTextArea from 'components/MentionTextArea'; +import { AvatarCircle } from 'components/shared/Avatar/Avatar'; +import IconButton from 'components/shared/IconButton'; +import useDiscussionParams from 'hooks/useDiscussionParams'; +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[]; + /** Truncates discussion content text with read more/less button */ + truncateText?: boolean; +}; + +/** + * Displays single discussion or reply + */ +const DiscussionPost = ({ + replies, + truncateText, + ...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]); + + const { pushDiscussionQuery } = useDiscussionParams(); + + return ( +
    +
    + +
    + +
    +
    +
    +

    {userAccount.commonName}

    + +
    + {role} +
    +
    + +

    + {upperFirst(getRelativeDate(createdAt))} +

    +
    + + + + { + // Only render reply data if `replies` is not undefined + replies && ( +
    + { + pushDiscussionQuery({ + discussionMode: 'reply', + discussionId: initialPost.id + }); + }} + 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/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 new file mode 100644 index 0000000000..ae5d4c6be5 --- /dev/null +++ b/src/views/DiscussionBoard/index.scss @@ -0,0 +1,39 @@ +@use 'uswds-core' as *; +@use 'viewports' as *; + +.easi-discussions { + .discussions-list { + .usa-accordion__content { + padding: 0 !important; + + ul li { + &:not(:last-child) { + border-bottom: 1px solid color('base-light'); + } + } + } + } + + .discussion-replies-thread { + li:not(:last-child) { + .easi-discussion-post { + > div:first-child { + position: relative; + + &::before { + content: ""; + position: absolute; + height: 100%; + width: calc(50% + 2px); + border-right: 4px solid color('base-lightest'); + z-index: -1; + } + } + + > div:last-child { + padding-bottom: 1.75rem; + } + } + } + } +} diff --git a/src/views/DiscussionBoard/index.tsx b/src/views/DiscussionBoard/index.tsx new file mode 100644 index 0000000000..68916efcc5 --- /dev/null +++ b/src/views/DiscussionBoard/index.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { + SystemIntakeGRBReviewDiscussionFragment, + SystemIntakeGRBReviewerFragment, + TagType +} from 'gql/gen/graphql'; + +import Alert from 'components/shared/Alert'; +import useDiscussionParams, { DiscussionMode } from 'hooks/useDiscussionParams'; +import { DiscussionAlert, MentionSuggestion } from 'types/discussions'; + +import Discussion from './Discussion'; +import DiscussionModalWrapper from './DiscussionModalWrapper'; +import StartDiscussion from './StartDiscussion'; +import ViewDiscussions from './ViewDiscussions'; + +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 */ + const [discussionAlert, setDiscussionAlert] = useState(null); + + const { getDiscussionParams, pushDiscussionQuery } = useDiscussionParams(); + const { discussionMode, discussionId } = getDiscussionParams(); + + // 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') { + setDiscussionAlert(null); + } + setLastMode(discussionMode); + } + }, [discussionMode, lastMode, setDiscussionAlert]); + + const closeModal = () => { + pushDiscussionQuery(false); + }; + + return ( + + {discussionAlert && ( + + {discussionAlert.message} + + )} + + {discussionMode === 'view' && ( + + )} + + {discussionMode === 'start' && ( + + )} + + {discussionMode === 'reply' && ( + + )} + + ); +} + +export default DiscussionBoard; diff --git a/src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx b/src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx new file mode 100644 index 0000000000..6d8b171acb --- /dev/null +++ b/src/views/GovernanceReviewTeam/GRBReview/Discussions.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { render, screen, within } from '@testing-library/react'; +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'; + +const [discussion] = mockDiscussions(); + +const discussionWithoutReplies: SystemIntakeGRBReviewDiscussionFragment = { + ...discussion, + 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', async () => { + render( + + + + + + ); + + expect( + await screen.findByRole('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', async () => { + render( + + + + + + ); + + expect( + await screen.findByText('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', async () => { + render( + + + + + + ); + + 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 new file mode 100644 index 0000000000..c985c078b2 --- /dev/null +++ b/src/views/GovernanceReviewTeam/GRBReview/Discussions.tsx @@ -0,0 +1,188 @@ +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { Button, Icon } from '@trussworks/react-uswds'; +import classNames from 'classnames'; +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, + grbReviewers, + className +}: DiscussionsProps) => { + const { t } = useTranslation('discussions'); + + const { pushDiscussionQuery } = useDiscussionParams(); + + const { data, loading } = useGetSystemIntakeGRBDiscussionsQuery({ + variables: { id: systemIntakeID } + }); + + const grbDiscussions: SystemIntakeGRBReviewDiscussionFragment[] | undefined = + data?.systemIntake?.grbDiscussions; + + if (!grbDiscussions) return null; + + const discussionsWithoutRepliesCount = grbDiscussions.filter( + discussion => discussion.replies.length === 0 + ).length; + + /** Discussion with latest activity - either when discussion was created or latest reply */ + const recentDiscussion = getMostRecentDiscussion(grbDiscussions); + + return ( + <> + + +
      +

      {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 && ( + { + pushDiscussionQuery({ discussionMode: 'view' }); + }} + icon={} + iconPosition="after" + unstyled + > + {t('general.view')} + + )} +
      + + {/* Recent discussions */} + {recentDiscussion ? ( + <> +

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

      + + {loading ? ( + + ) : ( + + )} + + ) : ( + // If no discussions, show alert + + { + pushDiscussionQuery({ discussionMode: 'start' }); + }} + unstyled + > + text + + ) + }} + /> + + )} +
      +
      + + ); +}; + +export default Discussions; diff --git a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx index c5bd0cefa6..7f913b9170 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.test.tsx @@ -151,8 +151,8 @@ const getSystemIntakeGRBReviewersQuery = ( systemIntake: { __typename: 'SystemIntake', id: systemIntake.id, - grbReviewStartedAt: null, - grbReviewers: reviewer ? [reviewer] : [] + grbReviewers: reviewer ? [reviewer] : [], + grbReviewStartedAt: null } } } diff --git a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.tsx b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.tsx index fee0568698..eee6a2063e 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/GRBReviewerForm/index.tsx @@ -41,12 +41,7 @@ const GRBReviewerForm = ({ }>(); const [mutate] = useCreateSystemIntakeGRBReviewersMutation({ - refetchQueries: [ - { - query: GetSystemIntakeGRBReviewersDocument, - variables: { id: systemId } - } - ] + refetchQueries: [GetSystemIntakeGRBReviewersDocument] }); 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.tsx b/src/views/GovernanceReviewTeam/GRBReview/index.tsx index 5574121ba2..84d6bec494 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/index.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/index.tsx @@ -37,6 +37,7 @@ import DocumentsTable from 'views/SystemIntake/Documents/DocumentsTable'; import ITGovAdminContext from '../ITGovAdminContext'; +import Discussions from './Discussions'; import GRBReviewerForm from './GRBReviewerForm'; import ParticipantsTable from './ParticipantsTable'; @@ -79,12 +80,7 @@ const GRBReview = ({ const { showMessage } = useMessage(); const [mutate] = useDeleteSystemIntakeGRBReviewerMutation({ - refetchQueries: [ - { - query: GetSystemIntakeGRBReviewersDocument, - variables: { id } - } - ] + refetchQueries: [GetSystemIntakeGRBReviewersDocument] }); const [startGRBReview] = useStartGRBReviewMutation({ @@ -363,6 +359,12 @@ const GRBReview = ({ + +