From 3ff8729295f0139928ff3001dc355111b19d87ba Mon Sep 17 00:00:00 2001 From: Jordan Wong <42422209+JorWo@users.noreply.github.com> Date: Fri, 8 Mar 2024 12:38:23 -1000 Subject: [PATCH 1/2] Implement email stack trace (#9) --- ui/.eslintrc.json | 2 +- ui/src/app/about/page.tsx | 3 +- ui/src/components/layout/navbar/Navbar.tsx | 2 +- ui/src/services/EmailService.ts | 22 ++- ui/src/services/FetchService.ts | 155 ++++++++++++---- ui/tests/app/about/About.test.tsx | 5 +- ui/tests/services/EmailService.test.ts | 27 ++- ui/tests/services/GroupingsApiService.test.ts | 165 +++++++++++++----- 8 files changed, 298 insertions(+), 83 deletions(-) diff --git a/ui/.eslintrc.json b/ui/.eslintrc.json index c0ce4add..a0f304c1 100644 --- a/ui/.eslintrc.json +++ b/ui/.eslintrc.json @@ -27,7 +27,7 @@ "eslint-plugin-tsdoc" ], "rules": { - "@stylistic/max-len": ["error", { "code": 120 }], + "@stylistic/max-len": ["warn", { "code": 120 }], "@stylistic/indent": ["error", 4], "@stylistic/eol-last": ["error", "always"], "@stylistic/quotes": ["error", "single", { "allowTemplateLiterals": true }], diff --git a/ui/src/app/about/page.tsx b/ui/src/app/about/page.tsx index 5d95aba3..48302376 100644 --- a/ui/src/app/about/page.tsx +++ b/ui/src/app/about/page.tsx @@ -60,8 +60,7 @@ const About = () => ( <h3 className="text-text-color text-lg pb-1">How do I request a new grouping?</h3> <p className="pb-7"> <a className="text-link-color hover:underline hover:text-link-hover-color" - href="https://uhawaii.atlassian.net/wiki/spaces/UHIAM/pages/13402308/ - UH+Groupings+Request+Form" + href="https://uhawaii.atlassian.net/wiki/spaces/UHIAM/pages/13402308/UH+Groupings+Request+Form" aria-label="A request form is available">A request form is available</a>. </p> diff --git a/ui/src/components/layout/navbar/Navbar.tsx b/ui/src/components/layout/navbar/Navbar.tsx index 6c4b1504..a775626b 100644 --- a/ui/src/components/layout/navbar/Navbar.tsx +++ b/ui/src/components/layout/navbar/Navbar.tsx @@ -10,7 +10,7 @@ const Navbar = async () => { const currentUser = await getCurrentUser(); return ( - <nav className="border-b-[1px] pointer-events-auto"> + <nav className="bg-white border-b-[1px] pointer-events-auto sticky top-0"> <div className="container py-2"> <div className="flex justify-between"> <Link href="/" className="lg:inline hidden"> diff --git a/ui/src/services/EmailService.ts b/ui/src/services/EmailService.ts index 9ac99b22..204ec896 100644 --- a/ui/src/services/EmailService.ts +++ b/ui/src/services/EmailService.ts @@ -22,8 +22,28 @@ export type EmailResult = { text: string } +/** + * Sends feedback to Groupings API to send email. + * + * @param feedback - the feedback + * + * @returns The EmailResult + */ export const sendFeedback = async (feedback: Feedback): Promise<EmailResult & ApiError> => { const currentUser = await getCurrentUser(); const endpoint = `${baseUrl}/email/send/feedback`; - return postRequest(endpoint, currentUser.uid, feedback); + return postRequest<EmailResult>(endpoint, currentUser.uid, feedback); +} + +/** + * Sends feedback to Groupings API to send stack trace email. + * + * @param stackTrace - the stack trace + * + * @returns The EmailResult + */ +export const sendStackTrace = async (stackTrace: string): Promise<EmailResult & ApiError> => { + const currentUser = await getCurrentUser(); + const endpoint = `${baseUrl}/email/send/stack-trace`; + return postRequest<EmailResult>(endpoint, currentUser.uid, stackTrace, 'text/plain'); } diff --git a/ui/src/services/FetchService.ts b/ui/src/services/FetchService.ts index fd2cfe93..4b2c73dd 100644 --- a/ui/src/services/FetchService.ts +++ b/ui/src/services/FetchService.ts @@ -2,10 +2,23 @@ import { ApiError } from '../groupings/GroupingsApiResults'; import { getCurrentUser } from '@/access/AuthenticationService'; +import { sendStackTrace } from './EmailService'; const maxRetries = 3; const baseUrl = process.env.NEXT_PUBLIC_API_2_1_BASE_URL as string; +enum HTTPMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE' +} + +enum Status { + COMPLETED = 'COMPLETED', + IN_PROGRESS = 'IN_PROGRESS' +} + /** * Sleep/wait for the specified milliseconds. * @@ -24,10 +37,10 @@ const delay = async (ms = 5000) => new Promise((res) => setTimeout(res, ms)); */ const poll = async <T> (jobId: number): Promise<T & ApiError> => { const currentUser = await getCurrentUser(); - return fetch(`${baseUrl}/jobs/${jobId}`, { headers: { 'current_user': currentUser.uid } }) - .then(res => res.json()) + return await fetch(`${baseUrl}/jobs/${jobId}`, { headers: { 'current_user': currentUser.uid } }) + .then(res => handleFetch(res, HTTPMethod.GET)) .then(async res => { - if (res.status === 'COMPLETED') { + if (res.status === Status.COMPLETED) { return res.result; } await delay(); @@ -49,7 +62,7 @@ export const getRequest = async <T> ( currentUserKey: string = '' ): Promise<T & ApiError> => await fetch(endpoint, { headers: { 'current_user': currentUserKey } }) - .then(res => res.json()) + .then(res => handleFetch(res, HTTPMethod.GET)) .catch(err => err); /** @@ -65,12 +78,17 @@ export const postRequest = async <T> ( endpoint: string, currentUserKey: string, body?: object | string | string[], + contentType = 'application/json' ): Promise<T & ApiError> => await fetch(endpoint, { - method: 'POST', - headers: { 'current_user': currentUserKey }, - body: JSON.stringify(body)}) - .then(res => res.json()) + method: HTTPMethod.POST, + headers: { + 'current_user': currentUserKey, + 'Content-Type': contentType + }, + body: stringifyBody(body) + }) + .then(res => handleFetch(res, HTTPMethod.POST)) .catch(err => err); /** @@ -85,10 +103,18 @@ export const postRequest = async <T> ( export const postRequestAsync = async <T> ( endpoint: string, currentUserKey: string, - body: string | string[] + body?: object | string | string[], + contentType = 'application/json' ): Promise<T & ApiError> => - await fetch(endpoint, { method: 'POST', headers: { 'current_user': currentUserKey }, body: JSON.stringify(body) }) - .then(res => res.json()) + await fetch(endpoint, { + method: HTTPMethod.POST, + headers: { + 'current_user': currentUserKey, + 'Content-Type': contentType + }, + body: stringifyBody(body) + }) + .then(res => handleFetch(res, HTTPMethod.POST)) .then(res => poll<T>(res)) .catch(err => err); @@ -104,16 +130,24 @@ export const postRequestAsync = async <T> ( export const postRequestRetry = async <T> ( endpoint: string, currentUserKey: string, - body: string | string[], + body?: object | string | string[], + contentType = 'application/json', retries: number = maxRetries ): Promise<T & ApiError> => - await fetch(endpoint, { method: 'POST', headers: { 'current_user': currentUserKey }, body: JSON.stringify(body) }) + await fetch(endpoint, { + method: HTTPMethod.POST, + headers: { + 'current_user': currentUserKey, + 'Content-Type': contentType + }, + body: stringifyBody(body) + }) .then(async res => { if (res.status === 500 && retries > 0) { await delay(2000 * Math.log(maxRetries / retries)); - return postRequestRetry(endpoint, currentUserKey, body, retries - 1); + return postRequestRetry(endpoint, currentUserKey, body, contentType, retries - 1); } - return res.json(); + return handleFetch(res, HTTPMethod.POST); }) .catch(err => err); @@ -130,13 +164,18 @@ export const postRequestRetry = async <T> ( export const putRequest = async <T> ( endpoint: string, currentUserKey: string, - body?: object | string | string[] + body?: object | string | string[], + contentType = 'application/json' ): Promise<T & ApiError> => await fetch(endpoint, { - method: 'PUT', - headers: { 'current_user': currentUserKey }, - body: JSON.stringify(body) }) - .then(res => res.json()) + method: HTTPMethod.PUT, + headers: { + 'current_user': currentUserKey, + 'Content-Type': contentType + }, + body: stringifyBody(body) + }) + .then(res => handleFetch(res, HTTPMethod.PUT)) .catch(err => err); /** @@ -151,10 +190,18 @@ export const putRequest = async <T> ( export const putRequestAsync = async <T> ( endpoint: string, currentUserKey: string, - body: string | string[] + body?: object | string | string[], + contentType = 'application/json' ): Promise<T & ApiError> => - await fetch(endpoint, { method: 'PUT', headers: { 'current_user': currentUserKey }, body: JSON.stringify(body) }) - .then(res => res.json()) + await fetch(endpoint, { + method: HTTPMethod.PUT, + headers: { + 'current_user': currentUserKey, + 'Content-Type': contentType + }, + body: stringifyBody(body) + }) + .then(res => handleFetch(res, HTTPMethod.PUT)) .then(res => poll<T>(res)) .catch(err => err); @@ -170,11 +217,18 @@ export const putRequestAsync = async <T> ( export const deleteRequest = async <T> ( endpoint: string, currentUserKey: string, - body?: object | string | string[] + body?: object | string | string[], + contentType = 'application/json' ): Promise<T & ApiError> => - await fetch(endpoint, { method: 'DELETE', headers: { 'current_user': currentUserKey }, body: JSON.stringify(body) }) - .then(res => res.json()) - .then(res => (res)) + await fetch(endpoint, { + method: HTTPMethod.DELETE, + headers: { + 'current_user': currentUserKey, + 'Content-Type': contentType + }, + body: stringifyBody(body) + }) + .then(res => handleFetch(res, HTTPMethod.DELETE)) .catch(err => err); /** @@ -189,12 +243,49 @@ export const deleteRequest = async <T> ( export const deleteRequestAsync = async <T> ( endpoint: string, currentUserKey: string, - body?: object | string | string[] + body?: object | string | string[], + contentType = 'application/json' ): Promise<T & ApiError> => await fetch(endpoint, { - method: 'DELETE', - headers: { 'current_user': currentUserKey }, - body: JSON.stringify(body) }) - .then(res => res.json()) + method: HTTPMethod.DELETE, + headers: { + 'current_user': currentUserKey, + 'Content-Type': contentType + }, + body: stringifyBody(body) + }) + .then(res => handleFetch(res, HTTPMethod.DELETE)) .then(res => poll<T>(res)) .catch(err => err); + +/** + * Helper function for the .then clause of a fetch promise. + * Sends an email stack trace if an error is thrown. + * + * @param res - the response + * @param httpMethod - the HTTPMethod + * + * @returns The res.json() + */ +const handleFetch = (res: Response, httpMethod: HTTPMethod) => { + if (!res.ok) { + const error = Error(`${res.status} error from ${httpMethod} ${res.url}`); + sendStackTrace(error.stack as string); + } + return res.json(); +} + +/** + * Helper function to JSON.stringify the body of a request or keep as a string + * if the passed body is already a string. + * + * @param body - the body of a request + * + * @returns The stringified body + */ +const stringifyBody = (body: object | string | string[] | undefined) => { + if (typeof body === 'string') { + return body + } + return JSON.stringify(body) +}; diff --git a/ui/tests/app/about/About.test.tsx b/ui/tests/app/about/About.test.tsx index 647c83dd..2d6a1af5 100644 --- a/ui/tests/app/about/About.test.tsx +++ b/ui/tests/app/about/About.test.tsx @@ -1,4 +1,4 @@ -import {render, screen} from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import About from '@/app/about/page'; describe('About', () => { @@ -14,8 +14,7 @@ describe('About', () => { expect(screen.getByText('GENERAL INFO')).toBeInTheDocument(); expect(screen.getByRole('link', {name: 'A request form is available'})) - .toHaveAttribute('href', 'https://uhawaii.atlassian.net/wiki/spaces/UHIAM/pages/13402308/' + - ' UH+Groupings+Request+Form'); + .toHaveAttribute('href', 'https://uhawaii.atlassian.net/wiki/spaces/UHIAM/pages/13402308/UH+Groupings+Request+Form'); expect(screen.getByRole('link', {name: 'General information about groupings is available'})) .toHaveAttribute('href', 'https://uhawaii.atlassian.net/wiki/spaces/UHIAM/pages/13403213/UH+Groupings'); diff --git a/ui/tests/services/EmailService.test.ts b/ui/tests/services/EmailService.test.ts index a59183b8..50d2662c 100644 --- a/ui/tests/services/EmailService.test.ts +++ b/ui/tests/services/EmailService.test.ts @@ -1,5 +1,5 @@ import User from '@/access/User'; -import { Feedback, sendFeedback } from '@/services/EmailService'; +import { Feedback, sendFeedback, sendStackTrace } from '@/services/EmailService'; import * as AuthenticationService from '@/access/AuthenticationService'; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL as string; @@ -10,7 +10,6 @@ jest.mock('@/access/AuthenticationService'); describe('EmailService', () => { const currentUser = testUser; - const headers = { 'current_user': currentUser.uid }; beforeAll(() => { jest.spyOn(AuthenticationService, 'getCurrentUser').mockResolvedValue(testUser); @@ -29,10 +28,30 @@ describe('EmailService', () => { await sendFeedback(feedback); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/email/send/feedback`, { body: JSON.stringify(feedback), - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'POST' }); }); - }); + + describe('sendStackTrace', () => { + + const stackTrace = 'stackTrace'; + + it('should make a POST request at the correct endpoint', async () => { + await sendStackTrace(stackTrace); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/email/send/stack-trace`, { + body: stackTrace, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'text/plain' + }, + method: 'POST' + }); + }); + }); + }); diff --git a/ui/tests/services/GroupingsApiService.test.ts b/ui/tests/services/GroupingsApiService.test.ts index 58004c8f..24e28875 100644 --- a/ui/tests/services/GroupingsApiService.test.ts +++ b/ui/tests/services/GroupingsApiService.test.ts @@ -42,11 +42,11 @@ const baseUrl = process.env.NEXT_PUBLIC_API_2_1_BASE_URL as string; const testUser: User = JSON.parse(process.env.TEST_USER_A as string); jest.mock('@/access/AuthenticationService'); +jest.mock('@/services/EmailService'); describe('GroupingsService', () => { const currentUser = testUser; - const headers = { 'current_user': currentUser.uid }; const uhIdentifier = 'testiwta'; const uhIdentifiers = ['testiwta', 'testiwtb']; @@ -117,7 +117,10 @@ describe('GroupingsService', () => { expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/group?` + `page=${page}&size=${size}&sortString=${sortString}&isAscending=${isAscending}`, { body: JSON.stringify(groupPaths), - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'POST' }); }); @@ -167,7 +170,9 @@ describe('GroupingsService', () => { describe('groupingDescription', () => { it('should make a GET request at the correct endpoint', async () => { await groupingDescription(groupingPath); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/description`, { headers }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/description`, { + headers: { 'current_user': currentUser.uid } + }); }); it('should handle the successful response', async () => { @@ -184,8 +189,9 @@ describe('GroupingsService', () => { describe('groupingSyncDest', () => { it('should make a GET request at the correct endpoint', async () => { await groupingSyncDest(groupingPath); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/groupings-sync-destinations`, - { headers }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/groupings-sync-destinations`, { + headers: { 'current_user': currentUser.uid } + }); }); it('should handle the successful response', async () => { @@ -202,8 +208,9 @@ describe('GroupingsService', () => { describe('groupingOptAttributes', () => { it('should make a GET request at the correct endpoint', async () => { await groupingOptAttributes(groupingPath); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/opt-attributes`, - { headers }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/opt-attributes`, { + headers: { 'current_user': currentUser.uid } + }); }); it('should handle the successful response', async () => { @@ -223,8 +230,11 @@ describe('GroupingsService', () => { it('should make a POST request at the correct endpoint', async () => { await updateDescription(description, groupingPath); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/description`, { - body: JSON.stringify(description), - headers, + body: description, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'POST' }); }); @@ -243,7 +253,9 @@ describe('GroupingsService', () => { describe('groupingAdmins', () => { it('should make a GET request at the correct endpoint', async () => { await groupingAdmins(); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/grouping-admins`, { headers }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/grouping-admins`, { + headers: { 'current_user': currentUser.uid } + }); }); it('should handle the successful response', async () => { @@ -260,7 +272,9 @@ describe('GroupingsService', () => { describe('getAllGroupings', () => { it('should make a GET request at the correct endpoint', async () => { await getAllGroupings(); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/all-groupings`, { headers }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/all-groupings`, { + headers: { 'current_user': currentUser.uid } + }); }); it('should handle the successful response', async () => { @@ -280,7 +294,10 @@ describe('GroupingsService', () => { expect(fetch) .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/include-members`, { body: JSON.stringify(uhIdentifiers), - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'PUT' }); }); @@ -310,7 +327,10 @@ describe('GroupingsService', () => { expect(fetch).toHaveBeenCalledWith( `${baseUrl}/groupings/${groupingPath}/include-members/async`,{ body: JSON.stringify(uhIdentifiers), - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'PUT' }); }); @@ -347,7 +367,10 @@ describe('GroupingsService', () => { expect(fetch) .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/exclude-members`, { body: JSON.stringify(uhIdentifiers), - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'PUT' }); }); @@ -377,7 +400,10 @@ describe('GroupingsService', () => { expect(fetch).toHaveBeenCalledWith( `${baseUrl}/groupings/${groupingPath}/exclude-members/async`,{ body: JSON.stringify(uhIdentifiers), - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'PUT' }); }); @@ -412,7 +438,10 @@ describe('GroupingsService', () => { it('should make a POST request at the correct endpoint', async () => { await addOwners(uhIdentifiers, groupingPath); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/owners/${uhIdentifiers}`, { - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'POST' }); }); @@ -432,7 +461,10 @@ describe('GroupingsService', () => { it('should make a POST request at the correct endpoint', async () => { await addAdmin(uhIdentifier); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/admins/${uhIdentifier}`, { - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'POST' }); }); @@ -452,7 +484,10 @@ describe('GroupingsService', () => { it('should make a DELETE request at the correct endpoint', async () => { await removeFromGroups(uhIdentifier, groupPaths); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/admins/${groupPaths}/${uhIdentifier}`, { - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'DELETE' }); }); @@ -473,7 +508,10 @@ describe('GroupingsService', () => { await removeIncludeMembers(uhIdentifiers, groupingPath); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/include-members`, { body: JSON.stringify(uhIdentifiers), - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'DELETE' }); }); @@ -494,7 +532,10 @@ describe('GroupingsService', () => { await removeExcludeMembers(uhIdentifiers, groupingPath); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/exclude-members`, { body: JSON.stringify(uhIdentifiers), - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'DELETE' }); }); @@ -514,7 +555,10 @@ describe('GroupingsService', () => { it('should make a DELETE request at the correct endpoint', async () => { await removeOwners(uhIdentifiers, groupingPath); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/owners/${uhIdentifiers}`, { - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'DELETE' }); }); @@ -534,7 +578,10 @@ describe('GroupingsService', () => { it('should make a DELETE request at the correct endpoint', async () => { await removeAdmin(uhIdentifier); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/admins/${uhIdentifier}`, { - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'DELETE' }); }); @@ -555,7 +602,10 @@ describe('GroupingsService', () => { await memberAttributeResults(uhIdentifiers); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members`, { body: JSON.stringify(uhIdentifiers), - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'POST' }); }); @@ -584,7 +634,10 @@ describe('GroupingsService', () => { await memberAttributeResultsAsync(uhIdentifiers); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members/async`, { body: JSON.stringify(uhIdentifiers), - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'POST' }); }); @@ -609,7 +662,7 @@ describe('GroupingsService', () => { fetchMock .mockResponseOnce(JSON.stringify(0)) .mockRejectOnce(() => Promise.reject(mockError)); - res = addExcludeMembersAsync(uhIdentifiers, groupingPath); + res = memberAttributeResultsAsync(uhIdentifiers); await jest.advanceTimersByTimeAsync(5000); expect(await res).toEqual(mockError); }); @@ -620,7 +673,10 @@ describe('GroupingsService', () => { await optIn(groupingPath); expect(fetch) .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/include-members/${currentUser.uid}/self`, { - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'PUT' }); }); @@ -641,7 +697,10 @@ describe('GroupingsService', () => { await optOut(groupingPath); expect(fetch) .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/exclude-members/${currentUser.uid}/self`, { - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'PUT' }) }); @@ -660,7 +719,9 @@ describe('GroupingsService', () => { describe('membershipResults', () => { it('should make a GET request at the correct endpoint', async () => { await membershipResults(); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members/${currentUser.uid}/memberships`, { headers }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members/${currentUser.uid}/memberships`, { + headers: { 'current_user': currentUser.uid } + }); }); it('should handle the successful response', async () => { @@ -677,7 +738,9 @@ describe('GroupingsService', () => { describe('managePersonResults', () => { it('should make a GET request at the correct endpoint', async () => { await managePersonResults(uhIdentifier); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members/${uhIdentifier}/groupings`, { headers }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members/${uhIdentifier}/groupings`, { + headers: { 'current_user': currentUser.uid } + }); }); it('should handle the successful response', async () => { @@ -694,7 +757,9 @@ describe('GroupingsService', () => { describe('getNumberOfMemberships', () => { it('should make a GET request at the correct endpoint', async () => { await getNumberOfMemberships(); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members/${currentUser.uid}/memberships/count`, { headers }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members/${currentUser.uid}/memberships/count`, { + headers: { 'current_user': currentUser.uid } + }); }); it('should handle the successful response', async () => { @@ -712,7 +777,9 @@ describe('GroupingsService', () => { it('should make a GET request at the correct endpoint', async () => { await optInGroupingPaths(); expect(fetch) - .toHaveBeenCalledWith(`${baseUrl}/groupings/members/${currentUser.uid}/opt-in-groups`, { headers }); + .toHaveBeenCalledWith(`${baseUrl}/groupings/members/${currentUser.uid}/opt-in-groups`, { + headers: { 'current_user': currentUser.uid } + }); }); it('should handle the successful response', async () => { @@ -731,7 +798,10 @@ describe('GroupingsService', () => { await resetIncludeGroup(groupingPath); expect(fetch) .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/include`, { - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'DELETE' }); }); @@ -760,7 +830,10 @@ describe('GroupingsService', () => { await resetIncludeGroupAsync(groupingPath); expect(fetch) .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/include/async`, { - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'DELETE' }); }); @@ -796,7 +869,10 @@ describe('GroupingsService', () => { await resetExcludeGroup(groupingPath); expect(fetch) .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/exclude`, { - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'DELETE' }); }); @@ -825,7 +901,10 @@ describe('GroupingsService', () => { await resetExcludeGroupAsync(groupingPath); expect(fetch) .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/exclude/async`, { - headers, + headers: { + 'current_user': currentUser.uid, + 'Content-Type': 'application/json' + }, method: 'DELETE' }); }); @@ -860,7 +939,9 @@ describe('GroupingsService', () => { it('should make a GET request at the correct endpoint', async () => { await groupingOwners(groupingPath); expect(fetch) - .toHaveBeenCalledWith(`${baseUrl}/grouping/${groupingPath}/owners`, { headers }); + .toHaveBeenCalledWith(`${baseUrl}/grouping/${groupingPath}/owners`, { + headers: { 'current_user': currentUser.uid } + }); }); it('should handle the successful response', async () => { @@ -878,7 +959,9 @@ describe('GroupingsService', () => { it('should make a GET request at the correct endpoint', async () => { await ownerGroupings(); expect(fetch) - .toHaveBeenCalledWith(`${baseUrl}/owners/${currentUser.uid}/groupings`, { headers }); + .toHaveBeenCalledWith(`${baseUrl}/owners/${currentUser.uid}/groupings`, { + headers: { 'current_user': currentUser.uid } + }); }); it('should handle the successful response', async () => { @@ -896,7 +979,9 @@ describe('GroupingsService', () => { it('should make a GET request at the correct endpoint', async () => { await getNumberOfGroupings(); expect(fetch) - .toHaveBeenCalledWith(`${baseUrl}/owners/${currentUser.uid}/groupings/count`, { headers }); + .toHaveBeenCalledWith(`${baseUrl}/owners/${currentUser.uid}/groupings/count`, { + headers: { 'current_user': currentUser.uid } + }); }); it('should handle the successful response', async () => { @@ -914,7 +999,9 @@ describe('GroupingsService', () => { it('should make a GET request at the correct endpoint', async () => { await isSoleOwner(uhIdentifier, groupingPath); expect(fetch) - .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/owners/${uhIdentifier}`, { headers }); + .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/owners/${uhIdentifier}`, { + headers: { 'current_user': currentUser.uid } + }); }); it('should handle the successful response', async () => { From df7c92e558650b989c211cbfa0c48499ee2d192a Mon Sep 17 00:00:00 2001 From: Jordan Wong <42422209+JorWo@users.noreply.github.com> Date: Fri, 8 Mar 2024 16:29:39 -1000 Subject: [PATCH 2/2] Create headers for /admin /groupings /memberships (#10) --- ui/package.json | 1 + ui/src/app/about/page.tsx | 2 +- ui/src/app/admin/page.tsx | 58 ++++++++++++ ui/src/app/groupings/page.tsx | 22 +++++ ui/src/app/memberships/page.tsx | 46 +++++++++ ui/src/components/ui/button.tsx | 8 +- ui/src/components/ui/sheet.tsx | 12 +-- ui/src/components/ui/tabs.tsx | 93 +++++++++++++++++++ .../about/{About.test.tsx => page.test.tsx} | 0 ui/tests/app/admin/page.test.tsx | 20 ++++ ui/tests/app/groupings/page.test.tsx | 15 +++ ui/tests/app/memberships/page.test.tsx | 19 ++++ 12 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 ui/src/app/admin/page.tsx create mode 100644 ui/src/app/groupings/page.tsx create mode 100644 ui/src/app/memberships/page.tsx create mode 100644 ui/src/components/ui/tabs.tsx rename ui/tests/app/about/{About.test.tsx => page.test.tsx} (100%) create mode 100644 ui/tests/app/admin/page.test.tsx create mode 100644 ui/tests/app/groupings/page.test.tsx create mode 100644 ui/tests/app/memberships/page.test.tsx diff --git a/ui/package.json b/ui/package.json index 11569645..1a100b52 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,6 +15,7 @@ "dependencies": { "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", "camaro": "^6.2.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/ui/src/app/about/page.tsx b/ui/src/app/about/page.tsx index 48302376..c8d17f53 100644 --- a/ui/src/app/about/page.tsx +++ b/ui/src/app/about/page.tsx @@ -2,7 +2,7 @@ import Image from 'next/image'; const About = () => ( <main> - <div className="bg-seafoam pt-10 pb-10"> + <div className="bg-seafoam py-10"> <div className="container"> <div className="grid gap-2"> <h1 className="text-center text-4xl font-medium">What is a UH Grouping?</h1> diff --git a/ui/src/app/admin/page.tsx b/ui/src/app/admin/page.tsx new file mode 100644 index 00000000..5ba4c690 --- /dev/null +++ b/ui/src/app/admin/page.tsx @@ -0,0 +1,58 @@ +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; + +const Admin = () => { + return ( + <main> + <div className="bg-seafoam pt-3"> + <div className="container"> + <h1 className="mb-1 font-bold text-[2rem] text-center md:text-left">UH Groupings Administration</h1> + <p className="pb-8 text-xl text-center md:text-left"> + Search for and manage any grouping on behalf of its owner. + Manage the list of UH Groupings administrators. + </p> + </div> + <Tabs defaultValue="manage-groupings"> + <div className="container"> + <TabsList variant="outline"> + <TabsTrigger + value="manage-groupings" variant="outline" > + Manage Groupings + </TabsTrigger> + <TabsTrigger + value="manage-admins" variant="outline" > + Manage Admins + </TabsTrigger> + <TabsTrigger + value="manage-person" variant="outline" > + Manage Person + </TabsTrigger> + </TabsList> + </div> + <TabsContent value="manage-groupings"> + <div className="bg-white"> + <div className="container"> + {/* GroupingsTable goes here */} + </div> + </div> + </TabsContent> + <TabsContent value="manage-admins"> + <div className="bg-white"> + <div className="container"> + {/* AdminTable goes here */} + </div> + </div> + </TabsContent> + <TabsContent value="manage-person"> + <div className="bg-white"> + <div className="container"> + {/* PersonTable goes here */} + </div> + </div> + </TabsContent> + </Tabs> + </div> + </main> + ); +} + +export default Admin; diff --git a/ui/src/app/groupings/page.tsx b/ui/src/app/groupings/page.tsx new file mode 100644 index 00000000..0824e8fa --- /dev/null +++ b/ui/src/app/groupings/page.tsx @@ -0,0 +1,22 @@ +const Groupings = () => { + return ( + <main> + <div className="bg-seafoam pt-3"> + <div className="container"> + <h1 className="mb-1 font-bold text-[2rem] text-center md:text-left">Manage My Groupings</h1> + <p className="pb-3 text-xl text-center md:text-left"> + View and manage groupings I own. Manage members, + configure grouping options and sync destinations. + </p> + </div> + <div className="bg-white"> + <div className="container"> + {/* GroupingsTable goes here */} + </div> + </div> + </div> + </main> + ); +} + +export default Groupings; diff --git a/ui/src/app/memberships/page.tsx b/ui/src/app/memberships/page.tsx new file mode 100644 index 00000000..09aa557b --- /dev/null +++ b/ui/src/app/memberships/page.tsx @@ -0,0 +1,46 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +const Memberships = () => { + return ( + <main> + <div className="bg-seafoam pt-3"> + <div className="container"> + <h1 className="mb-1 font-bold text-[2rem] text-center md:text-left">Manage My Memberships</h1> + <p className="pb-8 text-xl text-center md:text-left"> + View and manage my memberships. Search for new groupings to join as a member. + </p> + </div> + <Tabs defaultValue="current-memberships"> + <div className="container"> + <TabsList variant="outline"> + <TabsTrigger + value="current-memberships" variant="outline"> + Current Memberships + </TabsTrigger> + <TabsTrigger + value="membership-opportunities" variant="outline"> + Membership Opportunities + </TabsTrigger> + </TabsList> + </div> + <TabsContent value="current-memberships"> + <div className="bg-white"> + <div className="container"> + {/* MembershipsTable goes here */} + </div> + </div> + </TabsContent> + <TabsContent value="membership-opportunities"> + <div className="bg-white"> + <div className="container"> + {/* MembershipsTable goes here */} + </div> + </div> + </TabsContent> + </Tabs> + </div> + </main> + ); +} + +export default Memberships; diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx index 3c469dd0..60a2e796 100644 --- a/ui/src/components/ui/button.tsx +++ b/ui/src/components/ui/button.tsx @@ -1,8 +1,8 @@ -import * as React from 'react' -import { Slot } from '@radix-ui/react-slot' -import { cva, type VariantProps } from 'class-variance-authority' +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from '@/components/ui/utils' +import { cn } from '@/components/ui/utils'; const buttonVariants = cva( `inline-flex items-center justify-center whitespace-nowrap rounded-[0.25rem] text-base font-normal ring-offset-white diff --git a/ui/src/components/ui/sheet.tsx b/ui/src/components/ui/sheet.tsx index 3b2131bc..da7b8731 100644 --- a/ui/src/components/ui/sheet.tsx +++ b/ui/src/components/ui/sheet.tsx @@ -1,11 +1,11 @@ -'use client' +'use client'; -import * as React from 'react' -import * as SheetPrimitive from '@radix-ui/react-dialog' -import { cva, type VariantProps } from 'class-variance-authority' -import { X } from 'lucide-react' +import * as React from 'react'; +import * as SheetPrimitive from '@radix-ui/react-dialog'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { X } from 'lucide-react'; -import { cn } from '@/components/ui/utils' +import { cn } from '@/components/ui/utils'; const Sheet = SheetPrimitive.Root; diff --git a/ui/src/components/ui/tabs.tsx b/ui/src/components/ui/tabs.tsx new file mode 100644 index 00000000..341e986e --- /dev/null +++ b/ui/src/components/ui/tabs.tsx @@ -0,0 +1,93 @@ +'use client'; + +import * as React from 'react'; +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from './utils'; + +const Tabs = TabsPrimitive.Root + +const tabsListVariants = cva( + 'inline-flex', + { + variants: { + variant: { + default: 'items-center justify-center h-10 rounded-md bg-muted p-1 text-muted-foreground', + outline: '', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) +const tabsTriggerVariants = cva( + 'inline-flex items-center justify-center', + { + variants: { + variant: { + default: `whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background + transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring + focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 + data-[state=active]:bg-background data-[state=active]:text-foreground + data-[state=active]:shadow-sm`, + outline: `px-4 py-2 text-base font-medium ring-offset-background transition-all + data-[state=active]:bg-white data-[state=active]:rounded-t data-[state=active]:border-b-5 + data-[state=active]:border-black data-[state=active]:text-foreground text-muted-foreground`, + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) +interface TabsListProps + extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>, + VariantProps<typeof tabsListVariants> {} + +const TabsList = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.List>, + TabsListProps +>(({ className, variant, ...props }, ref) => ( + <TabsPrimitive.List + ref={ref} + className={cn(tabsListVariants({ variant, className }))} + {...props} + /> +)) +TabsList.displayName = TabsPrimitive.List.displayName + +interface TabsTriggerProps + extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>, + VariantProps<typeof tabsTriggerVariants> {} + +const TabsTrigger = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Trigger>, + TabsTriggerProps +>(({ className, variant, ...props }, ref) => ( + <TabsPrimitive.Trigger + ref={ref} + className={cn(tabsTriggerVariants({ variant, className }))} + {...props} + /> +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + + +const TabsContent = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.Content + ref={ref} + className={cn( + `ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring + focus-visible:ring-offset-2`, + className + )} + {...props} + /> +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/ui/tests/app/about/About.test.tsx b/ui/tests/app/about/page.test.tsx similarity index 100% rename from ui/tests/app/about/About.test.tsx rename to ui/tests/app/about/page.test.tsx diff --git a/ui/tests/app/admin/page.test.tsx b/ui/tests/app/admin/page.test.tsx new file mode 100644 index 00000000..f2f4cd68 --- /dev/null +++ b/ui/tests/app/admin/page.test.tsx @@ -0,0 +1,20 @@ +import Admin from '@/app/admin/page'; +import { render, screen } from '@testing-library/react'; + +describe('Admin', () => { + + it('should render the Admin page with the appropriate header and tabs', () => { + render(<Admin />); + expect(screen.getByRole('main')).toBeInTheDocument(); + + expect(screen.getByRole('heading', { name: 'UH Groupings Administration' })).toBeInTheDocument(); + expect(screen.getByText('Search for and manage any grouping on behalf of its owner. ' + + 'Manage the list of UH Groupings administrators.')).toBeInTheDocument(); + + expect(screen.getByRole('tablist')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Manage Groupings' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Manage Admins' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Manage Person' })).toBeInTheDocument(); + }); + +}); diff --git a/ui/tests/app/groupings/page.test.tsx b/ui/tests/app/groupings/page.test.tsx new file mode 100644 index 00000000..8562eda6 --- /dev/null +++ b/ui/tests/app/groupings/page.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from '@testing-library/react'; +import Groupings from '@/app/groupings/page'; + +describe('Groupings', () => { + + it('should render the Groupings page with the appropriate header', () => { + render(<Groupings />); + expect(screen.getByRole('main')).toBeInTheDocument(); + + expect(screen.getByRole('heading', { name: 'Manage My Groupings' })).toBeInTheDocument(); + expect(screen.getByText('View and manage groupings I own. ' + + 'Manage members, configure grouping options and sync destinations.')).toBeInTheDocument(); + }); + +}); diff --git a/ui/tests/app/memberships/page.test.tsx b/ui/tests/app/memberships/page.test.tsx new file mode 100644 index 00000000..54e8d714 --- /dev/null +++ b/ui/tests/app/memberships/page.test.tsx @@ -0,0 +1,19 @@ +import Memberships from '@/app/memberships/page'; +import { render, screen } from '@testing-library/react'; + +describe('Memberships', () => { + + it('should render the Memberhsips page with the appropriate header and tabs', () => { + render(<Memberships />); + expect(screen.getByRole('main')).toBeInTheDocument(); + + expect(screen.getByRole('heading', { name: 'Manage My Memberships' })).toBeInTheDocument(); + expect(screen.getByText('View and manage my memberships. ' + + 'Search for new groupings to join as a member.')).toBeInTheDocument(); + + expect(screen.getByRole('tablist')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Current Memberships' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Membership Opportunities' })).toBeInTheDocument(); + }); + +});