From 91b932bec204bca22c2356fdd0a56bff4ce3fe6d Mon Sep 17 00:00:00 2001 From: Aaron shiel <57824522+aaronshiel@users.noreply.github.com> Date: Tue, 15 Aug 2023 13:25:39 -0700 Subject: [PATCH] mentors need approval before going public (#300) * setup warns that mentor is not yet approved for public * can approve/un-approve mentors for public visibility --- client/src/api.ts | 29 ++++ .../components/setup/mentor-privacy-slide.tsx | 6 + client/src/helpers.ts | 18 ++- .../use-with-static-data-connection.ts | 12 +- client/src/hooks/graphql/use-with-users.tsx | 19 +++ client/src/pages/users.tsx | 108 ++++++++++++-- client/src/types-gql.ts | 2 + client/src/types.ts | 2 + cypress/cypress.json | 2 +- cypress/cypress/integration/setup.spec.ts | 24 +++ cypress/cypress/integration/users.spec.ts | 140 ++++++++++++++++++ cypress/cypress/support/functions.ts | 13 ++ cypress/cypress/support/types.ts | 1 + 13 files changed, 353 insertions(+), 23 deletions(-) diff --git a/client/src/api.ts b/client/src/api.ts index cce550849..ba4467695 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -337,6 +337,7 @@ export async function fetchUsers( isPrivate isArchived isAdvanced + isPublicApproved lastTrainStatus orgPermissions { orgId @@ -344,6 +345,7 @@ export async function fetchUsers( editPermission } updatedAt + createdAt } } } @@ -1030,6 +1032,7 @@ export async function fetchMentorById( dirtyReason isPrivate isArchived + isPublicApproved isAdvanced hasVirtualBackground virtualBackgroundUrl @@ -1286,6 +1289,31 @@ export async function updateUserDisabled( ); } +export async function updateMentorPublicApproval( + isPublicApproved: boolean, + accessToken: string, + mentorId: string +): Promise { + return execGql( + { + query: ` + mutation UpdateMentorPublicApproval($mentorId: ID!, $isPublicApproved: Boolean) { + me { + updateMentorPublicApproval(mentorId: $mentorId, isPublicApproved: $isPublicApproved) { + isPublicApproved + } + } + } + `, + variables: { + mentorId, + isPublicApproved, + }, + }, + { dataPath: ["me", "updateMentorPublicApproval"], accessToken } + ); +} + export async function updateAnswerUrl( accessToken: string, mentorId: string, @@ -2347,6 +2375,7 @@ export async function fetchMentors( title isPrivate isArchived + isPublicApproved isAdvanced orgPermissions { orgId diff --git a/client/src/components/setup/mentor-privacy-slide.tsx b/client/src/components/setup/mentor-privacy-slide.tsx index 6b7a893e8..b68cfdc4c 100644 --- a/client/src/components/setup/mentor-privacy-slide.tsx +++ b/client/src/components/setup/mentor-privacy-slide.tsx @@ -198,6 +198,12 @@ export function MentorPrivacySlide(props: { Organization Privacy Permissions: {renderOrgPermission()} + {!mentor.isPublicApproved ? ( + + Your mentor is not yet approved to be public. They will not be + visible to anyone until they are approved. + + ) : undefined} } /> diff --git a/client/src/helpers.ts b/client/src/helpers.ts index e04dde869..7e9bf0158 100644 --- a/client/src/helpers.ts +++ b/client/src/helpers.ts @@ -368,12 +368,12 @@ export function canEditMentorPrivacy( if (!mentor || !myUser) { return false; } - const ops = mentor.orgPermissions?.filter( - (op) => op.editPermission === OrgEditPermissionType.ADMIN + const orgPerms = mentor.orgPermissions?.filter( + (perm) => perm.editPermission === OrgEditPermissionType.ADMIN ); - if (ops) { - const os = ops.map((op) => op.orgId); - for (const org of orgs.filter((o) => os.includes(o._id))) { + if (orgPerms) { + const orgIds = orgPerms.map((op) => op.orgId); + for (const org of orgs.filter((o) => orgIds.includes(o._id))) { if ( org.members.find( (m) => m.user._id === myUser._id && m.role === UserRole.ADMIN @@ -483,3 +483,11 @@ interface EnumObject { export function getEnumValues(e: EnumObject): string[] { return Object.keys(e).map((i) => e[i]); } + +export function isDateWithinLastMonth(date: string): boolean { + const now = new Date(); + const then = new Date(date); + const diff = now.getTime() - then.getTime(); + const diffDays = Math.ceil(diff / (1000 * 3600 * 24)); + return diffDays < 30; +} diff --git a/client/src/hooks/graphql/use-with-static-data-connection.ts b/client/src/hooks/graphql/use-with-static-data-connection.ts index 9e2d8a378..13f3e30dd 100644 --- a/client/src/hooks/graphql/use-with-static-data-connection.ts +++ b/client/src/hooks/graphql/use-with-static-data-connection.ts @@ -71,7 +71,7 @@ export function useWithStaticDataConnection( if (!data) { return; } - const edges = sortFilter(data.edges); + const edges = sortFilter(data.edges, pageSearchParams); const pd: Connection = { edges: edges.slice(page - pageLimit, page), pageInfo: { @@ -94,7 +94,10 @@ export function useWithStaticDataConnection( }); }, [data, page, pageSearchParams, preFilter, postSort]); - function sortFilter(e: Edge[]): Edge[] { + function sortFilter( + e: Edge[], + pageSearchParams: StaticSearchParams + ): Edge[] { let edges = e.filter((edge) => !preFilter || preFilter.filter(edge.node)); if (pageSearchParams.sortBy) { const sortAscending = pageSearchParams.sortAscending ? 1 : -1; @@ -149,6 +152,9 @@ export function useWithStaticDataConnection( return 0; } } + if (typeof aVal === "boolean" && typeof bVal === "boolean") { + return (aVal ? 1 : 0 - (bVal ? 1 : 0)) * ascending; + } if (typeof aVal !== typeof bVal) { if (aVal === null || aVal === undefined) { return 1; @@ -237,7 +243,7 @@ export function useWithStaticDataConnection( } function hasNextPage(): boolean { - return sortFilter(data?.edges || []).length > page; + return sortFilter(data?.edges || [], pageSearchParams).length > page; } function hasPrevPage(): boolean { diff --git a/client/src/hooks/graphql/use-with-users.tsx b/client/src/hooks/graphql/use-with-users.tsx index 8cdee7fc6..07a5b554c 100644 --- a/client/src/hooks/graphql/use-with-users.tsx +++ b/client/src/hooks/graphql/use-with-users.tsx @@ -9,6 +9,7 @@ import { fetchUsers, updateMentorAdvanced, updateMentorPrivacy, + updateMentorPublicApproval, updateUserDisabled, updateUserPermissions, } from "api"; @@ -23,6 +24,7 @@ import { export interface UseUserData extends UseStaticDataConnection { userDataError?: LoadingError; onUpdateUserPermissions: (userId: string, permissionLevel: string) => void; + onUpdateMentorPublicApproved: (userId: string, isDisabled: boolean) => void; onUpdateUserDisabled: (userId: string, isDisabled: boolean) => void; onUpdateMentorPrivacy: (mentorId: string, isPrivate: boolean) => void; onUpdateMentorAdvanced: (mentorId: string, isAdvanced: boolean) => void; @@ -83,6 +85,22 @@ export function useWithUsers(accessToken: string): UseUserData { }); } + function onUpdateMentorPublicApproved( + mentorId: string, + isPublicApproved: boolean + ): void { + updateMentorPublicApproval(isPublicApproved, accessToken, mentorId) + .then(() => { + reloadData(); + }) + .catch((err) => { + setUserDataError({ + message: "Failed to update mentor for public approval", + error: `${err}`, + }); + }); + } + function onUpdateMentorPrivacy(mentorId: string, isPrivate: boolean): void { updateMentorPrivacy(accessToken, { isPrivate }, mentorId) .then(() => { @@ -144,6 +162,7 @@ export function useWithUsers(accessToken: string): UseUserData { onUpdateMentorPrivacy, onUpdateMentorAdvanced, onUpdateUserDisabled, + onUpdateMentorPublicApproved, onArchiveMentor, }; } diff --git a/client/src/pages/users.tsx b/client/src/pages/users.tsx index 1f9f47311..8965246df 100644 --- a/client/src/pages/users.tsx +++ b/client/src/pages/users.tsx @@ -25,6 +25,8 @@ import { Theme, SelectChangeEvent, Checkbox, + Button, + Typography, } from "@mui/material"; import { makeStyles } from "tss-react/mui"; import { @@ -57,6 +59,7 @@ import { canEditMentor, canEditMentorPrivacy, canEditUserRole, + isDateWithinLastMonth, launchMentor, } from "../helpers"; import useActiveMentor from "store/slices/mentor/useActiveMentor"; @@ -107,6 +110,14 @@ function getTableColumns( isAdmin: boolean ): ColumnDef[] { let columns: ColumnDef[] = [ + { + id: "defaultMentor", + subField: ["isPublicApproved"], + label: "Approval", + minWidth: 0, + align: "left", + sortable: true, + }, { id: "name", label: "Name", @@ -302,6 +313,18 @@ function UserItem(props: { const { switchActiveMentor } = useActiveMentor(); const userRole = user.userRole; const mentor = edge.node.defaultMentor; + const [approvalText, setApprovalText] = useState( + mentor.isPublicApproved ? "Approved" : "Not Approved" + ); + const [approvalTextColor, setApprovalTextColor] = useState( + mentor.isPublicApproved ? "green" : "red" + ); + + useEffect(() => { + setApprovalText(mentor.isPublicApproved ? "Approved" : "Not Approved"); + setApprovalTextColor(mentor.isPublicApproved ? "green" : "red"); + }, [mentor.isPublicApproved]); + const isAdmin = userRole === UserRole.ADMIN || userRole === UserRole.SUPER_ADMIN; @@ -309,6 +332,13 @@ function UserItem(props: { props.userPagin.onUpdateUserPermissions(user, permission); } + function handlePublicApprovalChange( + mentor: string, + isPublicApproved: boolean + ): void { + props.userPagin.onUpdateMentorPublicApproved(mentor, isPublicApproved); + } + function handleDisabledChange(user: string, isDisabled: boolean): void { props.userPagin.onUpdateUserDisabled(user, isDisabled); } @@ -324,9 +354,57 @@ function UserItem(props: { function handleArchiveChange(mentor: string, isArchived: boolean): void { props.userPagin.onArchiveMentor(mentor, isArchived); } - return ( + + <> + {isDateWithinLastMonth(mentor.createdAt) && + !mentor.isPublicApproved ? ( + + New Mentor + + ) : undefined} + + + {edge.node.name} @@ -430,19 +508,21 @@ function UserItem(props: { )} {isAdmin ? ( - - - handleDisabledChange(edge.node._id, !edge.node.isDisabled) - } - /> - + <> + + + handleDisabledChange(edge.node._id, !edge.node.isDisabled) + } + /> + + ) : undefined} {isAdmin ? ( diff --git a/client/src/types-gql.ts b/client/src/types-gql.ts index 13a689c07..eb4e2c435 100644 --- a/client/src/types-gql.ts +++ b/client/src/types-gql.ts @@ -61,6 +61,7 @@ export interface MentorGQL { isDirty: boolean; lastTrainStatus: JobState; dirtyReason: MentorDirtyReason; + isPublicApproved: boolean; isPrivate: boolean; isArchived: boolean; isAdvanced: boolean; @@ -72,6 +73,7 @@ export interface MentorGQL { answers: AnswerGQL[]; hasVirtualBackground: boolean; virtualBackgroundUrl: string; + createdAt: string; updatedAt: string; } diff --git a/client/src/types.ts b/client/src/types.ts index 56948cf45..6e811af60 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -192,6 +192,7 @@ export interface Mentor { isDirty: boolean; lastTrainStatus: JobState; dirtyReason: MentorDirtyReason; + isPublicApproved: boolean; isPrivate: boolean; isArchived: boolean; isAdvanced: boolean; @@ -204,6 +205,7 @@ export interface Mentor { hasVirtualBackground: boolean; virtualBackgroundUrl: string; updatedAt: string; + createdAt: string; numAnswersComplete: number; } diff --git a/cypress/cypress.json b/cypress/cypress.json index 36a88a639..f936b7439 100644 --- a/cypress/cypress.json +++ b/cypress/cypress.json @@ -1,5 +1,5 @@ { - "baseUrl": "http://localhost:80", + "baseUrl": "http://localhost:8000", "video": false, "$schema": "https://on.cypress.io/cypress.schema.json", "chromeWebSecurity": false, diff --git a/cypress/cypress/integration/setup.spec.ts b/cypress/cypress/integration/setup.spec.ts index 3c362dd20..f13f3adf7 100644 --- a/cypress/cypress/integration/setup.spec.ts +++ b/cypress/cypress/integration/setup.spec.ts @@ -608,6 +608,30 @@ describe("Setup", () => { cy.matchImageSnapshot(snapname("welcome-slide")); }); + it("non-public-approved mentors show warning", () => { + cyMockDefault(cy, { + ...baseMock, + mentor: { ...baseMock.mentor, isPublicApproved: false }, + }); + cyVisitSetupScreen(cy, SetupScreen.Mentor_Privacy); + cy.getSettled(`[data-cy=slide-${SetupScreen.Mentor_Privacy}]`) + .should("be.visible") + .within(($slide) => { + cy.contains("Your mentor is not yet approved to be public."); + }); + }); + + it("public-approved mentors does not show warning", () => { + cyMockDefault(cy, { + ...baseMock, + mentor: { ...baseMock.mentor, isPublicApproved: true }, + }); + cyVisitSetupScreen(cy, SetupScreen.Mentor_Privacy); + cy.getSettled(`[data-cy=slide-${SetupScreen.Mentor_Privacy}]`) + .should("be.visible") + .should("not.contain", "Your mentor is not yet approved to be public."); + }); + it("Shows the walkthrough link if receive data from graphql", () => { cyMockDefault(cy, { ...baseMock, diff --git a/cypress/cypress/integration/users.spec.ts b/cypress/cypress/integration/users.spec.ts index 65ff72499..88b65381a 100644 --- a/cypress/cypress/integration/users.spec.ts +++ b/cypress/cypress/integration/users.spec.ts @@ -88,6 +88,140 @@ describe("users screen", () => { }); }); + it("Mentors approved for public viewing show APPROVED", () => { + cyMockDefault(cy, { + mentor: [newMentor], + login: { + ...loginDefault, + user: { ...loginDefault.user, userRole: UserRole.ADMIN }, + }, + gqlQueries: [ + mockGQL("Users", [ + { + users: { + edges: [ + { + cursor: "cursor 1", + node: { + _id: "admin", + name: "Admin", + email: "admin@opentutor.org", + userRole: UserRole.ADMIN, + defaultMentor: { + _id: "clintanderson", + name: "Admin", + isPrivate: true, + isPublicApproved: true, + }, + }, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: "cursor 2", + }, + }, + }, + ]), + ], + }); + cy.visit("/users"); + cy.get("[data-cy=user-0]").within(($within) => { + cy.get("[data-cy=publicApprovalButton]") + .should("exist") + .should("have.text", "Approved"); + }); + }); + + it("Mentors not approved for public viewing show NOT APPROVED", () => { + cyMockDefault(cy, { + mentor: [newMentor], + login: { + ...loginDefault, + user: { ...loginDefault.user, userRole: UserRole.ADMIN }, + }, + gqlQueries: [ + mockGQL("Users", [ + { + users: { + edges: [ + { + cursor: "cursor 1", + node: { + _id: "admin", + name: "Admin", + email: "admin@opentutor.org", + userRole: UserRole.ADMIN, + defaultMentor: { + _id: "clintanderson", + name: "Admin", + isPrivate: true, + isPublicApproved: false, + }, + }, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: "cursor 2", + }, + }, + }, + ]), + ], + }); + cy.visit("/users"); + cy.get("[data-cy=user-0]").within(($within) => { + cy.get("[data-cy=publicApprovalButton]") + .should("exist") + .should("have.text", "Not Approved"); + }); + }); + + it("New mentors (created within last month) should indicate that it is a new mentor", () => { + cyMockDefault(cy, { + mentor: [newMentor], + login: { + ...loginDefault, + user: { ...loginDefault.user, userRole: UserRole.ADMIN }, + }, + gqlQueries: [ + mockGQL("Users", [ + { + users: { + edges: [ + { + cursor: "cursor 1", + node: { + _id: "admin", + name: "Admin", + email: "admin@opentutor.org", + userRole: UserRole.ADMIN, + defaultMentor: { + _id: "clintanderson", + name: "Admin", + isPrivate: true, + isPublicApproved: false, + createdAt: new Date(), + }, + }, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: "cursor 2", + }, + }, + }, + ]), + ], + }); + cy.visit("/users"); + cy.get("[data-cy=user-0]").within(($within) => { + cy.contains("New Mentor"); + }); + }); + it("admin can edit mentor privacy", () => { cyMockDefault(cy, { mentor: [newMentor], @@ -291,6 +425,7 @@ describe("users screen", () => { cy.wait(2000); cy.get("[data-cy=user-0]").within(($within) => { cy.get("[data-cy=train-mentor-clintanderson]") + .scrollIntoView() .should("exist") .should("be.visible"); }); @@ -342,6 +477,7 @@ describe("users screen", () => { cy.wait(2000); cy.get("[data-cy=user-0]").within(($within) => { cy.get("[data-cy=train-mentor-clintanderson]") + .scrollIntoView() .should("exist") .should("be.visible"); cy.get("[data-cy=train-mentor-clintanderson]").invoke("click"); @@ -400,6 +536,7 @@ describe("users screen", () => { cy.wait(2000); cy.get("[data-cy=user-0]").within(($within) => { cy.get("[data-cy=train-mentor-clintanderson1]") + .scrollIntoView() .should("exist") .should("be.visible"); cy.get("[data-cy=train-mentor-clintanderson1]").invoke("click"); @@ -478,6 +615,7 @@ describe("users screen", () => { cy.wait(2000); cy.get("[data-cy=user-0]").within(($within) => { cy.get("[data-cy=train-mentor-clintanderson0]") + .scrollIntoView() .should("exist") .should("be.visible"); cy.get("[data-cy=train-mentor-clintanderson0]").invoke("click"); @@ -487,6 +625,7 @@ describe("users screen", () => { cy.get("[data-cy=user-1]").within(($within) => { cy.get("[data-cy=train-mentor-clintanderson1]") + .scrollIntoView() .should("exist") .should("be.visible"); cy.get("[data-cy=train-mentor-clintanderson1]").invoke("click"); @@ -610,6 +749,7 @@ describe("users screen", () => { cy.wait(2000); cy.get("[data-cy=user-0]").within(($within) => { cy.get("[data-cy=train-mentor-clintanderson1]") + .scrollIntoView() .should("exist") .should("be.visible"); cy.get("[data-cy=train-mentor-clintanderson1]").invoke("click"); diff --git a/cypress/cypress/support/functions.ts b/cypress/cypress/support/functions.ts index 07d4fc8a7..ca52c67ef 100644 --- a/cypress/cypress/support/functions.ts +++ b/cypress/cypress/support/functions.ts @@ -240,6 +240,7 @@ export function cyMockDefault( cyMockGoogleLogin(cy); cyMockXapiInit(cy); cyMockThumbnailImage(cy); + cyMockAllImageRequests(cy); const mentors = []; if (args.mentor) { @@ -572,6 +573,18 @@ export function cyMockUploadThumbnail( }); } +function cyMockAllImageRequests(cy) { + cy.intercept("**/*.png", (req) => { + req.reply({ + statusCode: 200, + body: "Intercepted PNG response", + headers: { + "Content-Type": "image/png", + }, + }); + }); +} + function cyMockCancelUpload( cy, params: { diff --git a/cypress/cypress/support/types.ts b/cypress/cypress/support/types.ts index a07327eb2..e453c2190 100644 --- a/cypress/cypress/support/types.ts +++ b/cypress/cypress/support/types.ts @@ -90,6 +90,7 @@ export interface Mentor { lastTrainedAt: string; lastPreviewedAt: string; isDirty: boolean; + isPublicApproved: boolean; isPrivate: boolean; isArchived: boolean; isAdvanced: boolean;