diff --git a/packages/auth/src/Authenticator.js b/packages/auth/src/Authenticator.js index 86ca198796..b1d4c973ac 100644 --- a/packages/auth/src/Authenticator.js +++ b/packages/auth/src/Authenticator.js @@ -188,6 +188,7 @@ export class Authenticator { user_id: user.id, platform, app_version: appVersion, + last_login: new Date(), }, ); } diff --git a/packages/auth/src/__tests__/Authenticator/Authenticator.test.js b/packages/auth/src/__tests__/Authenticator/Authenticator.test.js index 3ea68aaf49..c4c364c75f 100644 --- a/packages/auth/src/__tests__/Authenticator/Authenticator.test.js +++ b/packages/auth/src/__tests__/Authenticator/Authenticator.test.js @@ -11,6 +11,15 @@ import { testAuthenticateRefreshToken } from './testAuthenticateRefreshToken'; jest.mock('rand-token'); randomToken.generate.mockReturnValue(refreshToken); +beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date(2020, 3, 1)); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + describe('Authenticator', () => { describe('authenticatePassword', testAuthenticatePassword); diff --git a/packages/auth/src/__tests__/Authenticator/testAuthenticatePassword.js b/packages/auth/src/__tests__/Authenticator/testAuthenticatePassword.js index bb2f221a16..16bb6d5bd1 100644 --- a/packages/auth/src/__tests__/Authenticator/testAuthenticatePassword.js +++ b/packages/auth/src/__tests__/Authenticator/testAuthenticatePassword.js @@ -165,6 +165,7 @@ export const testAuthenticatePassword = () => { user_id: verifiedUser.id, app_version: meditrakDeviceDetails.appVersion, platform: meditrakDeviceDetails.platform, + last_login: new Date(), }, ); diff --git a/packages/auth/src/userAuth.js b/packages/auth/src/userAuth.js index 1142b10484..8c5bde9a43 100644 --- a/packages/auth/src/userAuth.js +++ b/packages/auth/src/userAuth.js @@ -110,6 +110,7 @@ export const getAuthorizationObject = async ({ email: user.email, profileImage: user.profile_image, verifiedEmail: user.verified_email, + preferences: user.preferences, accessPolicy, }; if (permissionGroups) { diff --git a/packages/central-server/src/apiV2/import/importEntities/updateCountryEntities.js b/packages/central-server/src/apiV2/import/importEntities/updateCountryEntities.js index 8074550976..f77b4324f0 100644 --- a/packages/central-server/src/apiV2/import/importEntities/updateCountryEntities.js +++ b/packages/central-server/src/apiV2/import/importEntities/updateCountryEntities.js @@ -92,6 +92,7 @@ export async function updateCountryEntities( countryCode, pushToDhis, ); + await transactingModels.entity.findOrCreate( { code: countryCode }, { @@ -103,6 +104,7 @@ export async function updateCountryEntities( }, ); const codes = []; // An array to hold all facility codes, allowing duplicate checking + for (let i = 0; i < entityObjects.length; i++) { const entityObject = entityObjects[i]; const { entity_type: entityType } = entityObject; @@ -192,7 +194,27 @@ export async function updateCountryEntities( geojson.type === 'Polygon' ? { type: 'MultiPolygon', coordinates: [geojson.coordinates] } : geojson; - await transactingModels.entity.updateRegionCoordinates(code, translatedGeojson); + + try { + await transactingModels.entity.updateRegionCoordinates(code, translatedGeojson); + } catch (error) { + if (error.message.includes('payload string too long')) { + const largeGeoEntities = entityObjects.filter(entityObject => { + if (!entityObject?.geojson) return false; + const geoJsonString = JSON.stringify(entityObject.geojson); + // If the geo json is too large, we will hit the max payload size limit. + // Hard postgres max is 8000 characters, but we need to account for other data in the query payload + const maxGeoJsonPayload = 5200; + if (geoJsonString.length > maxGeoJsonPayload) { + return true; + } + }); + const text = largeGeoEntities.map(entity => entity.code).join(', '); + error.message = `Error updating region coordinates for entities: ${text} ${error.message}`; + } + + throw error; + } } } return country; diff --git a/packages/database/src/TupaiaDatabase.js b/packages/database/src/TupaiaDatabase.js index c8f875f7ad..eb56fbce78 100644 --- a/packages/database/src/TupaiaDatabase.js +++ b/packages/database/src/TupaiaDatabase.js @@ -662,7 +662,8 @@ function addWhereClause(connection, baseQuery, where) { return querySoFar; // Ignore undefined criteria } if (value === null) { - return querySoFar.whereNull(key); + const columnKey = getColSelector(connection, key); + return querySoFar.whereNull(columnKey); } const { comparisonType = 'where', @@ -748,5 +749,11 @@ function getColSelector(connection, inputColStr) { return connection.raw(inputColStr); } + const asGeoJsonPattern = /^ST_AsGeoJSON\((.+)\)$/; + if (asGeoJsonPattern.test(inputColStr)) { + const [, argsString] = inputColStr.match(asGeoJsonPattern); + return connection.raw(`ST_AsGeoJSON(${argsString})`); + } + return inputColStr; } diff --git a/packages/database/src/__tests__/modelClasses/SurveyResponse.test.js b/packages/database/src/__tests__/modelClasses/SurveyResponse.test.js new file mode 100644 index 0000000000..108037112d --- /dev/null +++ b/packages/database/src/__tests__/modelClasses/SurveyResponse.test.js @@ -0,0 +1,67 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { getLeaderboard } from '../../modelClasses/SurveyResponse'; + +const USERS_EXCLUDED_FROM_LEADER_BOARD = [ + "'edmofro@gmail.com'", + "'kahlinda.mahoney@gmail.com'", + "'lparish1980@gmail.com'", + "'sus.lake@gmail.com'", + "'michaelnunan@hotmail.com'", + "'vanbeekandrew@gmail.com'", + "'gerardckelly@gmail.com'", + "'geoffreyfisher@hotmail.com'", + "'unicef.laos.edu@gmail.com'", +]; +const SYSTEM_USERS = ["'tamanu-server@tupaia.org'", "'public@tupaia.org'", "'josh@sussol.net'"]; + +const whitespace = /\s/g; +const expectToBe = (expected, received) => { + expect(received.replace(whitespace, '')).toBe(expected.replace(whitespace, '')); +}; + +describe('getLeaderboard()', () => { + it('should filter out internal users on standard projects', async () => { + const expectedLeaderboard = `SELECT r.user_id, user_account.first_name, user_account.last_name, r.coconuts, r.pigs + FROM ( + SELECT user_id, FLOOR(COUNT(*)) as coconuts, FLOOR(COUNT(*) / 100) as pigs + FROM survey_response + JOIN survey on survey.id=survey_id + WHERE survey.project_id = ? + GROUP BY user_id + ) r + JOIN user_account on user_account.id = r.user_id + WHERE email NOT IN (${[...SYSTEM_USERS, ...USERS_EXCLUDED_FROM_LEADER_BOARD].join(', ')}) + AND email NOT LIKE '%@beyondessential.com.au' AND email NOT LIKE '%@bes.au' + ORDER BY coconuts DESC + LIMIT ?;`; + + expectToBe(getLeaderboard('5dfc6eaf61f76a497716cddf'), expectedLeaderboard); + }); + + it('should not filter out internal users on internal projects', async () => { + const INTERNAL_PROJECT_IDS = [ + '6684ac9d0f018e110b000a00', // bes_asset_demo + '66a03660718c54751609eeed', // bes_asset_tracker + '6704622a45a4fc4941071605', // bes_reporting + ]; + const expectedLeaderboard = `SELECT r.user_id, user_account.first_name, user_account.last_name, r.coconuts, r.pigs + FROM ( + SELECT user_id, FLOOR(COUNT(*)) as coconuts, FLOOR(COUNT(*) / 100) as pigs + FROM survey_response + JOIN survey on survey.id=survey_id + WHERE survey.project_id = ? + GROUP BY user_id + ) r + JOIN user_account on user_account.id = r.user_id + WHERE email NOT IN (${SYSTEM_USERS.join(', ')}) + ORDER BY coconuts DESC + LIMIT ?;`; + + INTERNAL_PROJECT_IDS.forEach(projectId => { + expectToBe(getLeaderboard(projectId), expectedLeaderboard); + }); + }); +}); diff --git a/packages/database/src/migrations/20241003224849-addDateColumnToMeditrakDeviceTable-modifies-schema.js b/packages/database/src/migrations/20241003224849-addDateColumnToMeditrakDeviceTable-modifies-schema.js new file mode 100644 index 0000000000..2a2adb9e02 --- /dev/null +++ b/packages/database/src/migrations/20241003224849-addDateColumnToMeditrakDeviceTable-modifies-schema.js @@ -0,0 +1,32 @@ +'use strict'; + +var dbm; +var type; +var seed; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = function (db) { + return db.runSql(` + ALTER TABLE meditrak_device + ADD COLUMN last_login TIMESTAMP; + `); +}; + +exports.down = function (db) { + return db.runSql(` + ALTER TABLE meditrak_device DROP COLUMN last_login; + `); +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/modelClasses/SurveyResponse.js b/packages/database/src/modelClasses/SurveyResponse.js index 4bffa63794..5fdaf34ddf 100644 --- a/packages/database/src/modelClasses/SurveyResponse.js +++ b/packages/database/src/modelClasses/SurveyResponse.js @@ -16,12 +16,44 @@ const USERS_EXCLUDED_FROM_LEADER_BOARD = [ "'vanbeekandrew@gmail.com'", // Andrew "'gerardckelly@gmail.com'", // Gerry K "'geoffreyfisher@hotmail.com'", // Geoff F - "'josh@sussol.net'", // mSupply API Client "'unicef.laos.edu@gmail.com'", // Laos Schools Data Collector +]; +const SYSTEM_USERS = [ "'tamanu-server@tupaia.org'", // Tamanu Server "'public@tupaia.org'", // Public User + "'josh@sussol.net'", // mSupply API Client ]; const INTERNAL_EMAIL = ['@beyondessential.com.au', '@bes.au']; +const INTERNAL_PROJECT_IDS = [ + '6684ac9d0f018e110b000a00', // bes_asset_demo + '66a03660718c54751609eeed', // bes_asset_tracker + '6704622a45a4fc4941071605', // bes_reporting +]; + +export function getLeaderboard(projectId = '') { + const isInternalProject = projectId && INTERNAL_PROJECT_IDS.includes(projectId); + + const besUsersFilter = `AND ${INTERNAL_EMAIL.map(email => `email NOT LIKE '%${email}'`).join(' AND ')}`; + const excludedUserAccountList = isInternalProject + ? SYSTEM_USERS + : [...SYSTEM_USERS, ...USERS_EXCLUDED_FROM_LEADER_BOARD]; + + // FLOOR to force result to be returned as int, not string + return `SELECT r.user_id, user_account.first_name, user_account.last_name, r.coconuts, r.pigs + FROM ( + SELECT user_id, FLOOR(COUNT(*)) as coconuts, FLOOR(COUNT(*) / 100) as pigs + FROM survey_response + JOIN survey on survey.id=survey_id + ${projectId ? 'WHERE survey.project_id = ?' : ''} + GROUP BY user_id + ) r + JOIN user_account on user_account.id = r.user_id + WHERE email NOT IN (${excludedUserAccountList.join(',')}) + ${!isInternalProject ? besUsersFilter : ''} + ORDER BY coconuts DESC + LIMIT ?; + `; +} export class SurveyResponseRecord extends DatabaseRecord { static databaseRecord = RECORDS.SURVEY_RESPONSE; @@ -38,23 +70,7 @@ export class SurveyResponseModel extends MaterializedViewLogDatabaseModel { async getLeaderboard(projectId = '', rowCount = 10) { const bindings = projectId ? [projectId, rowCount] : [rowCount]; - return this.database.executeSql( - `SELECT r.user_id, user_account.first_name, user_account.last_name, r.coconuts, r.pigs - FROM ( - SELECT user_id, FLOOR(COUNT(*)) as coconuts, FLOOR(COUNT(*) / 100) as pigs - -- ^~~~~~~~~~~~~~~ FLOOR to force result to be returned as int, not string - FROM survey_response - JOIN survey on survey.id=survey_id - ${projectId ? 'WHERE survey.project_id = ?' : ''} - GROUP BY user_id - ) r - JOIN user_account on user_account.id = r.user_id - WHERE ${INTERNAL_EMAIL.map(email => `email NOT LIKE '%${email}'`).join(' AND ')} - AND email NOT IN (${USERS_EXCLUDED_FROM_LEADER_BOARD.join(',')}) - ORDER BY coconuts DESC - LIMIT ?; - `, - bindings, - ); + const query = getLeaderboard(projectId); + return this.database.executeSql(query, bindings); } } diff --git a/packages/datatrak-web/src/api/queries/useProjects.ts b/packages/datatrak-web/src/api/queries/useProjects.ts index 7459a1121d..8be6ee3cda 100644 --- a/packages/datatrak-web/src/api/queries/useProjects.ts +++ b/packages/datatrak-web/src/api/queries/useProjects.ts @@ -7,6 +7,28 @@ import { useQuery } from '@tanstack/react-query'; import { DatatrakWebProjectsRequest } from '@tupaia/types'; import { get } from '../api'; -export const useProjects = () => { - return useQuery(['projects'], (): Promise => get('projects')); +export const useProjects = (sortByAccess = true) => { + const { data, ...query } = useQuery( + ['projects'], + (): Promise => get('projects'), + ); + + if (data && sortByAccess) { + data.sort((a, b) => { + // Sort by hasAccess = true first + if (a.hasAccess !== b.hasAccess) { + return a.hasAccess ? -1 : 1; + } + + // Sort by hasPendingAccess = true second + if (a.hasPendingAccess !== b.hasPendingAccess) { + return a.hasPendingAccess ? -1 : 1; + } + + // Otherwise, sort alphabetically by name + return a.name.localeCompare(b.name); + }); + } + + return { ...query, data }; }; diff --git a/packages/datatrak-web/src/components/ChangeProjectButton.tsx b/packages/datatrak-web/src/components/ChangeProjectButton.tsx index 9337d41161..9c779ac1f7 100644 --- a/packages/datatrak-web/src/components/ChangeProjectButton.tsx +++ b/packages/datatrak-web/src/components/ChangeProjectButton.tsx @@ -77,7 +77,7 @@ export const ChangeProjectButton = ({ className }: { className?: string }) => { {projectName ?? 'Select project'} - {projectModalIsOpen && } + {projectModalIsOpen && } ); }; diff --git a/packages/datatrak-web/src/components/SelectList/index.ts b/packages/datatrak-web/src/components/SelectList/index.ts deleted file mode 100644 index 1dd5c6bbdb..0000000000 --- a/packages/datatrak-web/src/components/SelectList/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd - */ - -export { SelectList } from './SelectList'; -export type { ListItemType } from './ListItem'; diff --git a/packages/datatrak-web/src/components/index.ts b/packages/datatrak-web/src/components/index.ts index f4ae7fc9f5..22e5a55246 100644 --- a/packages/datatrak-web/src/components/index.ts +++ b/packages/datatrak-web/src/components/index.ts @@ -5,7 +5,6 @@ export { PageContainer } from './PageContainer'; export * from './Icons'; -export * from './SelectList'; export { Autocomplete, QuestionAutocomplete } from './Autocomplete'; export { Button } from './Button'; export { ButtonLink } from './ButtonLink'; diff --git a/packages/datatrak-web/src/features/EntitySelector/ResultsList.tsx b/packages/datatrak-web/src/features/EntitySelector/ResultsList.tsx index bde1e21c8c..012298ed58 100644 --- a/packages/datatrak-web/src/features/EntitySelector/ResultsList.tsx +++ b/packages/datatrak-web/src/features/EntitySelector/ResultsList.tsx @@ -3,12 +3,12 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -import React from 'react'; +import React, { ReactNode } from 'react'; import styled from 'styled-components'; -import { Typography } from '@material-ui/core'; +import { FormLabelProps, Typography } from '@material-ui/core'; import RoomIcon from '@material-ui/icons/Room'; import { DatatrakWebEntityDescendantsRequest } from '@tupaia/types'; -import { ListItemType, SelectList } from '../../components'; +import { SelectList } from '@tupaia/ui-components'; const DARK_BLUE = '#004975'; @@ -43,6 +43,20 @@ export const ResultItem = ({ name, parentName }) => { ); }; +type ListItemType = Record & { + children?: ListItemType[]; + content: string | ReactNode; + value: string; + selected?: boolean; + icon?: ReactNode; + tooltip?: string; + button?: boolean; + disabled?: boolean; + labelProps?: FormLabelProps & { + component?: React.ElementType; + }; +}; + type SearchResults = DatatrakWebEntityDescendantsRequest.ResBody; interface ResultsListProps { value: string; diff --git a/packages/datatrak-web/src/features/GroupedSurveyList.tsx b/packages/datatrak-web/src/features/GroupedSurveyList.tsx index 222457548a..c32fd13e30 100644 --- a/packages/datatrak-web/src/features/GroupedSurveyList.tsx +++ b/packages/datatrak-web/src/features/GroupedSurveyList.tsx @@ -2,11 +2,12 @@ * Tupaia * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import React, { useEffect } from 'react'; +import React, { ReactNode, useEffect } from 'react'; import styled from 'styled-components'; import { FormHelperText, FormLabelProps } from '@material-ui/core'; import { Country } from '@tupaia/types'; -import { ListItemType, SelectList, SurveyFolderIcon, SurveyIcon } from '../components'; +import { SelectList } from '@tupaia/ui-components'; +import { SurveyFolderIcon, SurveyIcon } from '../components'; import { Survey } from '../types'; import { useCurrentUserContext, useProjectSurveys } from '../api'; @@ -21,6 +22,20 @@ const ListWrapper = styled.div` } `; +type ListItemType = Record & { + children?: ListItemType[]; + content: string | ReactNode; + value: string; + selected?: boolean; + icon?: ReactNode; + tooltip?: string; + button?: boolean; + disabled?: boolean; + labelProps?: FormLabelProps & { + component?: React.ElementType; + }; +}; + const sortAlphanumerically = (a: ListItemType, b: ListItemType) => { return (a.content as string).trim()?.localeCompare((b.content as string).trim(), 'en', { numeric: true, diff --git a/packages/datatrak-web/src/features/RequestProjectAccess.tsx b/packages/datatrak-web/src/features/RequestProjectAccess.tsx index 4e98d364c7..252dc0004b 100644 --- a/packages/datatrak-web/src/features/RequestProjectAccess.tsx +++ b/packages/datatrak-web/src/features/RequestProjectAccess.tsx @@ -10,17 +10,17 @@ import { useCountryAccessList, useProject, useRequestProjectAccess } from '../ap interface RequestProjectAccessProps { variant?: 'page' | 'modal'; projectCode?: string; - onClose?: () => void; + onBack?: () => void; } -export const RequestProjectAccess = ({ projectCode, onClose }: RequestProjectAccessProps) => { +export const RequestProjectAccess = ({ projectCode, onBack }: RequestProjectAccessProps) => { const { data: project, isLoading: isLoadingProject, isFetched } = useProject(projectCode); const { mutate: requestProjectAccess, isLoading, isSuccess } = useRequestProjectAccess(); const { data: countries } = useCountryAccessList(projectCode); return ( void; + onBack: () => void; } -export const ProjectSelectModal = ({ onClose }: ModalProps) => { +export const ProjectSelectModal = ({ onBack }: ModalProps) => { const { projectId } = useCurrentUserContext(); const [requestAccessProjectCode, setRequestAccessProjectCode] = useState(null); + const { data: projects, isLoading } = useProjects(); + const { mutate: onConfirm, isLoading: isConfirming } = useEditUser(onBack); return ( // Enable the portal so it displays over any other content and we don't get z-index issues - + {requestAccessProjectCode ? ( setRequestAccessProjectCode(null)} + onBack={() => setRequestAccessProjectCode(null)} /> ) : ( )} diff --git a/packages/datatrak-web/src/layout/UserMenu/UserMenu.tsx b/packages/datatrak-web/src/layout/UserMenu/UserMenu.tsx index a876eed5bd..a8a5e750ac 100644 --- a/packages/datatrak-web/src/layout/UserMenu/UserMenu.tsx +++ b/packages/datatrak-web/src/layout/UserMenu/UserMenu.tsx @@ -58,7 +58,7 @@ export const UserMenu = () => { onCloseMenu={onCloseMenu} openProjectModal={openProjectModal} /> - {projectModalOpen && } + {projectModalOpen && } ); }; diff --git a/packages/datatrak-web/src/views/ProjectSelectPage.tsx b/packages/datatrak-web/src/views/ProjectSelectPage.tsx index f2028248eb..459f2be317 100644 --- a/packages/datatrak-web/src/views/ProjectSelectPage.tsx +++ b/packages/datatrak-web/src/views/ProjectSelectPage.tsx @@ -6,9 +6,10 @@ import React from 'react'; import styled from 'styled-components'; import { Paper } from '@material-ui/core'; import { useNavigate } from 'react-router-dom'; +import { ProjectSelectForm } from '@tupaia/ui-components'; import { useFromLocation } from '../utils'; -import { ProjectSelectForm } from '../features'; import { ROUTES } from '../constants'; +import { useEditUser, useProjects } from '../api'; const Container = styled(Paper).attrs({ variant: 'outlined', @@ -19,9 +20,19 @@ const Container = styled(Paper).attrs({ `; export const ProjectSelectPage = () => { + const { data: projects, isLoading } = useProjects(); + const { mutate: onConfirm, isLoading: isConfirming } = useEditUser(); + const navigate = useNavigate(); const from = useFromLocation(); + const onRequestAccess = (projectCode: string) => { + navigate({ + pathname: ROUTES.REQUEST_ACCESS, + search: `?project=${projectCode}`, + }); + }; + const onSuccess = () => { navigate(from || ROUTES.HOME, { state: null, @@ -30,7 +41,14 @@ export const ProjectSelectPage = () => { return ( - + ); }; diff --git a/packages/datatrak-web/src/views/RequestProjectAccessPage.tsx b/packages/datatrak-web/src/views/RequestProjectAccessPage.tsx index 18d0393f99..615ae0905c 100644 --- a/packages/datatrak-web/src/views/RequestProjectAccessPage.tsx +++ b/packages/datatrak-web/src/views/RequestProjectAccessPage.tsx @@ -34,7 +34,7 @@ export const RequestProjectAccessPage = () => { return ( ; user?: { email: string; accessPolicy: AccessPolicyObject; diff --git a/packages/server-boilerplate/src/orchestrator/index.ts b/packages/server-boilerplate/src/orchestrator/index.ts index 6b27f30c67..7af5bafa69 100644 --- a/packages/server-boilerplate/src/orchestrator/index.ts +++ b/packages/server-boilerplate/src/orchestrator/index.ts @@ -8,3 +8,4 @@ export { SessionModel, SessionRecord } from './models'; export { SessionCookie } from './types'; export { attachSession, attachSessionIfAvailable } from './session'; export { SessionSwitchingAuthHandler, RequiresSessionAuthHandler } from './auth'; +export { LoginRoute, LoginRequest } from './routes'; diff --git a/packages/server-utils/src/downloadPageAsPDF.ts b/packages/server-utils/src/downloadPageAsPDF.ts index 97c7a42faf..7d3a2e8768 100644 --- a/packages/server-utils/src/downloadPageAsPDF.ts +++ b/packages/server-utils/src/downloadPageAsPDF.ts @@ -32,6 +32,8 @@ const buildParams = (pdfPageUrl: string, userCookie: string, cookieDomain: strin return { verifiedPDFPageUrl, cookies: finalisedCookieObjects }; }; +const pageNumberHTML = `
`; + /** * @param pdfPageUrl the url to visit and download as a pdf * @param userCookie the user's cookie to bypass auth, and ensure page renders under the correct user context @@ -43,6 +45,7 @@ export const downloadPageAsPDF = async ( userCookie = '', cookieDomain: string | undefined, landscape = false, + includePageNumber = false, ) => { let browser; let buffer; @@ -57,6 +60,12 @@ export const downloadPageAsPDF = async ( format: 'a4', printBackground: true, landscape, + displayHeaderFooter: includePageNumber, + // remove the default header so that only the page number is displayed, not a header + headerTemplate: `
`, + footerTemplate: pageNumberHTML, + //add a margin so the page number doesn't overlap with the content, and the top margin is set for overflow content + margin: includePageNumber ? { bottom: '10mm', top: '10mm' } : undefined, }); } catch (e) { throw new Error(`puppeteer error: ${(e as Error).message}`); diff --git a/packages/tupaia-web-server/src/app/createApp.ts b/packages/tupaia-web-server/src/app/createApp.ts index 36e2626598..5599e14e2c 100644 --- a/packages/tupaia-web-server/src/app/createApp.ts +++ b/packages/tupaia-web-server/src/app/createApp.ts @@ -64,6 +64,8 @@ export async function createApp(db: TupaiaDatabase = new TupaiaDatabase()) { 'requestCountryAccess', handleWith(routes.RequestCountryAccessRoute), ) + // @ts-ignore LoginRoute types cannot be extended at this time + .post('loginUser', handleWith(routes.LoginRoute)) .get('entity/:projectCode/:entityCode', handleWith(routes.EntityRoute)) .get( 'entities/:projectCode/:rootEntityCode', @@ -100,6 +102,7 @@ export async function createApp(db: TupaiaDatabase = new TupaiaDatabase()) { ) .use('downloadFiles', forwardRequest(CENTRAL_API_URL, { authHandlerProvider })) .use('me/countries', forwardRequest(CENTRAL_API_URL, { authHandlerProvider })) + .use('me', forwardRequest(CENTRAL_API_URL, { authHandlerProvider })) // Forward everything else to webConfigApi .use('dashboards', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) .use('export/chart', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) diff --git a/packages/tupaia-web-server/src/routes/LoginRoute.ts b/packages/tupaia-web-server/src/routes/LoginRoute.ts new file mode 100644 index 0000000000..9b29b5d4a9 --- /dev/null +++ b/packages/tupaia-web-server/src/routes/LoginRoute.ts @@ -0,0 +1,28 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { + LoginRoute as BaseLoginRoute, + LoginRequest as BaseLoginRequest, +} from '@tupaia/server-boilerplate'; +import { getProjectById } from '../utils'; + +export type LoginRequest = BaseLoginRequest; + +type UserResponse = Record; + +export class LoginRoute extends BaseLoginRoute { + // @ts-ignore LoginRoute types cannot be extended at this time + public async buildResponse() { + const { ctx } = this.req; + const authResponse = await super.buildResponse(); + const user: UserResponse = authResponse.user; + + const projectId = user?.preferences?.project_id; + if (projectId) { + user.project = await getProjectById(ctx, projectId); + } + return user; + } +} diff --git a/packages/tupaia-web-server/src/routes/UserRoute.ts b/packages/tupaia-web-server/src/routes/UserRoute.ts index 1afd457952..32c23889b7 100644 --- a/packages/tupaia-web-server/src/routes/UserRoute.ts +++ b/packages/tupaia-web-server/src/routes/UserRoute.ts @@ -2,10 +2,10 @@ * Tupaia * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ - import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; import { TupaiaWebUserRequest } from '@tupaia/types'; +import { getProjectById } from '../utils'; export type UserRequest = Request< TupaiaWebUserRequest.Params, @@ -28,8 +28,28 @@ export class UserRoute extends Route { first_name: firstName, last_name: lastName, email, + preferences, } = await ctx.services.central.getUser(); - return { userName: `${firstName} ${lastName}`, email }; + const userResponse: { + userName: string; + email: string; + project?: { + id: string; + name: string; + code: string; + homeEntityCode: string; + dashboardGroupName: string; + defaultMeasure: string; + }; + } = { userName: `${firstName} ${lastName}`, email }; + + const projectId = preferences?.project_id; + + if (projectId) { + userResponse.project = await getProjectById(ctx, projectId); + } + + return userResponse; } } diff --git a/packages/tupaia-web-server/src/routes/index.ts b/packages/tupaia-web-server/src/routes/index.ts index ee95acce3d..e8939e01a6 100644 --- a/packages/tupaia-web-server/src/routes/index.ts +++ b/packages/tupaia-web-server/src/routes/index.ts @@ -36,7 +36,6 @@ export { UnsubscribeDashboardMailingListRoute, UnsubscribeDashboardMailingListRequest, } from './UnsubscribeDashboardMailingListRoute'; - export { ExportMapOverlayRequest, ExportMapOverlayRoute } from './ExportMapOverlayRoute'; - +export { LoginRoute, LoginRequest } from './LoginRoute'; export { CountriesRequest, CountriesRoute } from './CountriesRoute'; diff --git a/packages/tupaia-web-server/src/utils/downloadDashboardAsPdf.ts b/packages/tupaia-web-server/src/utils/downloadDashboardAsPdf.ts index b915a28ce1..65f47c86e1 100644 --- a/packages/tupaia-web-server/src/utils/downloadDashboardAsPdf.ts +++ b/packages/tupaia-web-server/src/utils/downloadDashboardAsPdf.ts @@ -18,6 +18,7 @@ export const downloadDashboardAsPdf = ( settings: TupaiaWebExportDashboardRequest.ReqBody['settings'] = { exportWithLabels: false, exportWithTable: false, + separatePagePerItem: true, }, ) => { const endpoint = `${projectCode}/${entityCode}/${dashboardName}/dashboard-pdf-export`; @@ -26,5 +27,5 @@ export const downloadDashboardAsPdf = ( settings: JSON.stringify(settings), }); - return downloadPageAsPDF(pdfPageUrl, cookie, cookieDomain); + return downloadPageAsPDF(pdfPageUrl, cookie, cookieDomain, false, true); }; diff --git a/packages/tupaia-web-server/src/utils/getProjectById.ts b/packages/tupaia-web-server/src/utils/getProjectById.ts new file mode 100644 index 0000000000..ae6b39675f --- /dev/null +++ b/packages/tupaia-web-server/src/utils/getProjectById.ts @@ -0,0 +1,28 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +/** + * Fetches a project by its ID from webConfig server + * @param ctx + * @param projectId + */ +export const getProjectById = async (ctx: any, projectId: string) => { + const { projects = [] } = await ctx.services.webConfig.fetchProjects({ + showExcludedProjects: false, + }); + + const { id, name, code, homeEntityCode, dashboardGroupName, defaultMeasure } = projects.find( + ({ id }: { id: string }) => id === projectId, + ); + + return { + id, + name, + code, + homeEntityCode, + dashboardGroupName, + defaultMeasure, + }; +}; diff --git a/packages/tupaia-web-server/src/utils/index.ts b/packages/tupaia-web-server/src/utils/index.ts index 23a79f848f..15be9aaaf5 100644 --- a/packages/tupaia-web-server/src/utils/index.ts +++ b/packages/tupaia-web-server/src/utils/index.ts @@ -8,3 +8,4 @@ export { generateFrontendExcludedFilter, getTypesToExclude, } from './generateFrontendExcludedFilter'; +export { getProjectById } from './getProjectById'; diff --git a/packages/tupaia-web/package.json b/packages/tupaia-web/package.json index d8e46853e7..d3f03074f7 100644 --- a/packages/tupaia-web/package.json +++ b/packages/tupaia-web/package.json @@ -41,6 +41,7 @@ "react-dom": "^16.13.1", "react-hook-form": "^6.15.1", "react-leaflet": "^3.2.1", + "react-qr-reader": "^3.0.0-beta-1", "react-router": "6.3.0", "react-router-dom": "6.3.0", "react-slick": "^0.30.2", diff --git a/packages/tupaia-web/src/ModalRoutes.tsx b/packages/tupaia-web/src/ModalRoutes.tsx index a74c5c8cc4..30781c4656 100644 --- a/packages/tupaia-web/src/ModalRoutes.tsx +++ b/packages/tupaia-web/src/ModalRoutes.tsx @@ -13,6 +13,7 @@ import { RequestProjectAccessModal, ForgotPasswordModal, ResetPasswordModal, + ProjectSelectModal, } from './views'; import { useModal, useGAEffect } from './utils'; @@ -25,6 +26,7 @@ export const ModalRoutes = () => { const modalViews = { [MODAL_ROUTES.PROJECTS]: ProjectsModal, + [MODAL_ROUTES.PROJECT_SELECT]: ProjectSelectModal, [MODAL_ROUTES.LOGIN]: LoginModal, [MODAL_ROUTES.REGISTER]: RegisterModal, [MODAL_ROUTES.REQUEST_PROJECT_ACCESS]: RequestProjectAccessModal, diff --git a/packages/tupaia-web/src/Routes.tsx b/packages/tupaia-web/src/Routes.tsx index 9365bd2a1c..9c0c1a8375 100644 --- a/packages/tupaia-web/src/Routes.tsx +++ b/packages/tupaia-web/src/Routes.tsx @@ -19,9 +19,14 @@ import { LoadingScreen } from './components'; import { gaEvent, useEntityLink } from './utils'; const HomeRedirect = () => { - const { isLoggedIn } = useUser(); + const { isLoggedIn, data } = useUser(); gaEvent('Navigate', 'Go Home'); + if (data?.project) { + const { code, homeEntityCode, dashboardGroupName } = data.project; + return ; + } + return ( ; + +export const useEditUser = (onSuccess?: (data: UserAccountDetails) => void) => { + const queryClient = useQueryClient(); + + return useMutation( + async ({ projectId }: Record) => { + const data = { + project_id: projectId, + }; + await put('me', { data }); + return { + projectId, + }; + }, + { + onSuccess: data => { + queryClient.invalidateQueries(['getUser']); + if (onSuccess) onSuccess(data); + }, + }, + ); +}; diff --git a/packages/tupaia-web/src/api/mutations/useLogin.ts b/packages/tupaia-web/src/api/mutations/useLogin.ts index bf5fa0f726..a170a2daf7 100644 --- a/packages/tupaia-web/src/api/mutations/useLogin.ts +++ b/packages/tupaia-web/src/api/mutations/useLogin.ts @@ -23,7 +23,7 @@ export const useLogin = () => { return useMutation( ({ email, password }: LoginCredentials) => { - return post('login', { + return post('loginUser', { data: { emailAddress: email, password, @@ -35,7 +35,7 @@ export const useLogin = () => { onMutate: () => { gaEvent('User', 'Log in', 'Attempt'); }, - onSuccess: () => { + onSuccess: data => { gaEvent('User', 'Login', 'success'); queryClient.invalidateQueries(); // if the user was redirected to the login page, redirect them back to the page they were on @@ -44,7 +44,12 @@ export const useLogin = () => { state: null, }); } else if (location.pathname.includes(DEFAULT_PROJECT_ENTITY)) { - navigateToModal(MODAL_ROUTES.PROJECTS); + if (data.project) { + const { code, homeEntityCode, dashboardGroupName } = data.project; + navigate(`/${code}/${homeEntityCode}/${dashboardGroupName}`); + } else { + navigateToModal(MODAL_ROUTES.PROJECTS); + } } else { closeModal(); } diff --git a/packages/tupaia-web/src/api/queries/useProject.ts b/packages/tupaia-web/src/api/queries/useProject.ts index 571695e080..360ac762c2 100644 --- a/packages/tupaia-web/src/api/queries/useProject.ts +++ b/packages/tupaia-web/src/api/queries/useProject.ts @@ -5,6 +5,7 @@ */ import { useQuery } from '@tanstack/react-query'; +import { useLocation } from 'react-router-dom'; import { SingleProject } from '../../types'; import { get } from '../api'; import { useUser } from './useUser'; @@ -13,6 +14,7 @@ import { MODAL_ROUTES, URL_SEARCH_PARAMS } from '../../constants'; export const useProject = (projectCode?: string) => { const { isLoggedIn } = useUser(); + const location = useLocation(); const { navigateToModal, navigateToLogin } = useModal(); return useQuery( ['project', projectCode], @@ -21,14 +23,21 @@ export const useProject = (projectCode?: string) => { enabled: !!projectCode, keepPreviousData: false, // this needs to be false, otherwise when we change project, the old one is returned for until the new data is fetched, which leads to extra requests to the wrong project+entity code onSuccess: (data: SingleProject) => { - if (data?.hasAccess) return; - if (isLoggedIn) + const locationIsRequestAccess = + location.hash.replace(/^#/, '') === MODAL_ROUTES.REQUEST_PROJECT_ACCESS; + + if (data?.hasAccess || locationIsRequestAccess) { + return; + } + + if (isLoggedIn && !locationIsRequestAccess) { return navigateToModal(MODAL_ROUTES.REQUEST_PROJECT_ACCESS, [ { param: URL_SEARCH_PARAMS.PROJECT, value: projectCode!, }, ]); + } return navigateToLogin(); }, }, diff --git a/packages/tupaia-web/src/api/queries/useProjects.ts b/packages/tupaia-web/src/api/queries/useProjects.ts index 226f140446..2766f9b86a 100644 --- a/packages/tupaia-web/src/api/queries/useProjects.ts +++ b/packages/tupaia-web/src/api/queries/useProjects.ts @@ -7,18 +7,12 @@ import { useQuery } from '@tanstack/react-query'; import { get } from '../api'; export const useProjects = () => { - return useQuery( - ['projects'], - () => - get('projects', { - params: { - showExcludedProjects: false, - }, - }), - { - placeholderData: { - projects: [], + return useQuery(['projects'], async () => { + const projectsResponse = await get('projects', { + params: { + showExcludedProjects: false, }, - }, - ); + }); + return projectsResponse?.projects.sort((a, b) => a.name.localeCompare(b.name)); + }); }; diff --git a/packages/tupaia-web/src/constants/url.ts b/packages/tupaia-web/src/constants/url.ts index d643f53eb5..e2e36f78ca 100644 --- a/packages/tupaia-web/src/constants/url.ts +++ b/packages/tupaia-web/src/constants/url.ts @@ -16,6 +16,7 @@ export const URL_SEARCH_PARAMS = { export enum MODAL_ROUTES { PROJECTS = 'projects', + PROJECT_SELECT = 'project-select', LOGIN = 'login', REGISTER = 'register', FORGOT_PASSWORD = 'forgot-password', diff --git a/packages/tupaia-web/src/features/Dashboard/Dashboard.tsx b/packages/tupaia-web/src/features/Dashboard/Dashboard.tsx index cf426d6fca..09bf480255 100644 --- a/packages/tupaia-web/src/features/Dashboard/Dashboard.tsx +++ b/packages/tupaia-web/src/features/Dashboard/Dashboard.tsx @@ -11,7 +11,7 @@ import { DEFAULT_BOUNDS } from '@tupaia/ui-map-components'; import { ErrorBoundary, SpinningLoader } from '@tupaia/ui-components'; import { MatrixConfig } from '@tupaia/types'; import { MOBILE_BREAKPOINT } from '../../constants'; -import { useDashboards, useEntity, useProject } from '../../api/queries'; +import { useDashboards, useEntity, useProject, useUser } from '../../api/queries'; import { DashboardItem as DashboardItemType } from '../../types'; import { gaEvent, getDefaultDashboard } from '../../utils'; import { DashboardItem } from '../DashboardItem'; @@ -23,6 +23,7 @@ import { StaticMap } from './StaticMap'; import { ExportDashboard } from './ExportDashboard'; import { DashboardContextProvider, useDashboard } from './utils'; import { SubscribeModal, DashboardMenu } from './DashboardMenu'; +import { useEditUser } from '../../api/mutations'; const MAX_SIDEBAR_EXPANDED_WIDTH = 1000; const MAX_SIDEBAR_COLLAPSED_WIDTH = 550; @@ -34,7 +35,9 @@ const Panel = styled.div<{ }>` position: relative; background-color: ${({ theme }) => theme.palette.background.paper}; - transition: width 0.3s ease, max-width 0.3s ease; + transition: + width 0.3s ease, + max-width 0.3s ease; width: 100%; overflow: visible; min-height: 100%; @@ -110,6 +113,14 @@ export const Dashboard = () => { const location = useLocation(); const { projectCode, entityCode } = useParams(); const { data: project, isLoading: isLoadingProject } = useProject(projectCode); + const { data: user } = useUser(); + const { mutate: updateUser } = useEditUser(); + + useEffect(() => { + if (project?.code !== user?.project?.code) { + updateUser({ projectId: project?.id }); + } + }, [project?.code, user?.project?.code]); const { activeDashboard } = useDashboard(); const { diff --git a/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportConfig.tsx b/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportConfig.tsx index 0de00fbd73..19a84c3488 100644 --- a/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportConfig.tsx +++ b/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportConfig.tsx @@ -10,7 +10,11 @@ import { Button, LoadingContainer } from '@tupaia/ui-components'; import { useEntity, useProject } from '../../../api/queries'; import { useExportDashboard } from '../../../api/mutations'; import { DashboardItemVizTypes, MOBILE_BREAKPOINT } from '../../../constants'; -import { DisplayOptionsSettings, useExportSettings } from '../../ExportSettings'; +import { + DisplayFormatSettings, + DisplayOptionsSettings, + useExportSettings, +} from '../../ExportSettings'; import { useDashboard } from '../utils'; import { ExportSubtitle } from './ExportSubtitle'; import { MailingListSection } from './MailingListSection'; @@ -99,6 +103,17 @@ const ExportSettingsInstructionsContainer = styled.div` padding-bottom: 1.4rem; `; +const ExportSettingsWrapper = styled.div` + padding-block-end: 2rem; + & + & { + padding-block-start: 1.5rem; + border-top: 0.1rem solid ${({ theme }) => theme.palette.text.secondary}; + } + &:last-child { + padding-block-end: 0; + } +`; + interface ExportDashboardProps { onClose: () => void; selectedDashboardItems: string[]; @@ -109,7 +124,7 @@ export const ExportConfig = ({ onClose, selectedDashboardItems }: ExportDashboar const { data: project } = useProject(projectCode); const { data: entity } = useEntity(projectCode, entityCode); const { activeDashboard } = useDashboard(); - const { exportWithLabels, exportWithTable } = useExportSettings(); + const { exportWithLabels, exportWithTable, separatePagePerItem } = useExportSettings(); const exportFileName = `${project?.name}-${entity?.name}-${dashboardName}-dashboard-export`; @@ -124,6 +139,7 @@ export const ExportConfig = ({ onClose, selectedDashboardItems }: ExportDashboar settings: { exportWithLabels, exportWithTable, + separatePagePerItem, }, }); @@ -149,7 +165,12 @@ export const ExportConfig = ({ onClose, selectedDashboardItems }: ExportDashboar {hasChartItems && (
- + + + + + +
)}
- {!isLoading && } + {!isLoading && ( + + )}
diff --git a/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportDashboard.tsx b/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportDashboard.tsx index c518077087..d9d475e663 100644 --- a/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportDashboard.tsx +++ b/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportDashboard.tsx @@ -87,6 +87,7 @@ export const ExportDashboard = () => { exportWithLabels: false, exportWithTable: true, exportWithTableDisabled: false, + separatePagePerItem: true, }} > diff --git a/packages/tupaia-web/src/features/Dashboard/ExportDashboard/Preview.tsx b/packages/tupaia-web/src/features/Dashboard/ExportDashboard/Preview.tsx index 955c1f0df0..2124e62557 100644 --- a/packages/tupaia-web/src/features/Dashboard/ExportDashboard/Preview.tsx +++ b/packages/tupaia-web/src/features/Dashboard/ExportDashboard/Preview.tsx @@ -7,6 +7,7 @@ import React, { useState } from 'react'; import styled from 'styled-components'; import { Typography } from '@material-ui/core'; import { Pagination } from '@material-ui/lab'; +import { A4Page } from '@tupaia/ui-components'; import { DashboardPDFExport } from '../../../views'; import { MOBILE_BREAKPOINT } from '../../../constants'; @@ -46,6 +47,13 @@ const PreviewContainer = styled.div` min-width: 20rem; overflow-y: auto; overflow-x: hidden; + ${A4Page} { + // simulate the margins of the printed page + padding-block-start: 5rem; + &:last-child { + padding-block-end: 5rem; + } + } `; const PreviewTitle = styled(Typography).attrs({ @@ -57,24 +65,34 @@ const PreviewTitle = styled(Typography).attrs({ line-height: 1.4; `; -export const Preview = ({ selectedDashboardItems }: { selectedDashboardItems: string[] }) => { +export const Preview = ({ + selectedDashboardItems, + separatePagePerItem, +}: { + selectedDashboardItems: string[]; + separatePagePerItem: boolean; +}) => { const [page, setPage] = useState(1); const onPageChange = (_: unknown, newPage: number) => setPage(newPage); - const visualisationToPreview = selectedDashboardItems[page - 1]; + const visualisationToPreview = separatePagePerItem + ? [selectedDashboardItems[page - 1]] + : selectedDashboardItems; return ( Preview - + {separatePagePerItem && ( + + )} - + ); diff --git a/packages/tupaia-web/src/features/EntitySearch/EntitySearch.tsx b/packages/tupaia-web/src/features/EntitySearch/EntitySearch.tsx index dc2569364d..f5f250ba74 100644 --- a/packages/tupaia-web/src/features/EntitySearch/EntitySearch.tsx +++ b/packages/tupaia-web/src/features/EntitySearch/EntitySearch.tsx @@ -15,23 +15,25 @@ import { SearchResults } from './SearchResults'; import { gaEvent } from '../../utils'; const Container = styled.div` - position: relative; display: flex; + z-index: 1; flex-direction: column; align-items: center; margin-right: 1rem; margin-top: 0.6rem; width: 19rem; + position: relative; @media screen and (max-width: ${MOBILE_BREAKPOINT}) { width: auto; margin: 0; + position: initial; } `; const ResultsWrapper = styled.div` position: absolute; top: 100%; - left: 0; + right: 0; background: ${({ theme }) => theme.palette.background.paper}; padding: 0 0.3rem 0.625rem; width: calc(100% + 5px); @@ -40,14 +42,14 @@ const ResultsWrapper = styled.div` overflow-y: auto; @media screen and (max-width: ${MOBILE_BREAKPOINT}) { - position: fixed; + width: 100%; top: ${TOP_BAR_HEIGHT_MOBILE}; left: 0; right: 0; - z-index: 1; min-height: calc(100vh - ${TOP_BAR_HEIGHT_MOBILE}); max-height: calc(100vh - ${TOP_BAR_HEIGHT_MOBILE}); border-radius: 0; + position: fixed; } `; diff --git a/packages/tupaia-web/src/features/EntitySearch/SearchBar.tsx b/packages/tupaia-web/src/features/EntitySearch/SearchBar.tsx index 6bc38a2ee8..7eb3b4001b 100644 --- a/packages/tupaia-web/src/features/EntitySearch/SearchBar.tsx +++ b/packages/tupaia-web/src/features/EntitySearch/SearchBar.tsx @@ -8,12 +8,13 @@ import styled from 'styled-components'; import { TextField, TextFieldProps } from '@material-ui/core'; import SearchIcon from '@material-ui/icons/Search'; import { Close, Search } from '@material-ui/icons'; -import { MOBILE_BREAKPOINT, TOP_BAR_HEIGHT_MOBILE } from '../../constants'; import { IconButton } from '@tupaia/ui-components'; +import { MOBILE_BREAKPOINT, TOP_BAR_HEIGHT_MOBILE } from '../../constants'; +import { QRCodeScanner } from '../QRCodeScanner'; const SearchInput = styled(TextField).attrs({ variant: 'outlined', - placeholder: 'Search location', + placeholder: 'Search location...', fullWidth: true, InputProps: { startAdornment: , @@ -61,14 +62,9 @@ const SearchInput = styled(TextField).attrs({ `; const MobileCloseButton = styled(IconButton)` - display: none; - @media screen and (max-width: ${MOBILE_BREAKPOINT}) { - display: block; - position: absolute; - top: 0.1rem; - right: 0.1rem; - z-index: 1; - } + top: 0.1rem; + right: 0.1rem; + z-index: 1; `; const Container = styled.div<{ @@ -86,6 +82,8 @@ const Container = styled.div<{ height: ${TOP_BAR_HEIGHT_MOBILE}; // Place on top of the hamburger menu on mobile z-index: 1; + display: flex; + background: ${({ theme }) => theme.palette.background.paper}; } `; @@ -95,6 +93,12 @@ const MobileOpenButton = styled(IconButton)` display: block; } `; +const MobileWrapper = styled.div` + display: none; + @media screen and (max-width: ${MOBILE_BREAKPOINT}) { + display: flex; + } +`; interface SearchBarProps { value?: string; @@ -140,9 +144,12 @@ export const SearchBar = ({ value = '', onChange, onFocusChange, onClose }: Sear onFocus={() => onFocusChange(true)} inputRef={inputRef} /> - - - + + + + + + ); diff --git a/packages/tupaia-web/src/features/ExportSettings/DisplayFormatSettings.tsx b/packages/tupaia-web/src/features/ExportSettings/DisplayFormatSettings.tsx new file mode 100644 index 0000000000..5b2589d1e1 --- /dev/null +++ b/packages/tupaia-web/src/features/ExportSettings/DisplayFormatSettings.tsx @@ -0,0 +1,45 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import styled from 'styled-components'; +import { FormControl, FormControlLabel, Radio, RadioGroup } from '@material-ui/core'; +import { useExportSettings } from './ExportSettingsContext'; +import { ExportSettingLabel } from './ExportSettingLabel'; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; +`; + +const RadioItem = styled(FormControlLabel)` + padding-block-end: 0.625rem; + &:first-child { + padding-block-start: 0.625rem; + } + .MuiButtonBase-root { + padding-block: 0; + } +`; + +export const DisplayFormatSettings = () => { + const { separatePagePerItem, updateSeparatePagePerItem } = useExportSettings(); + + return ( + + + Format + + } label="One per page" /> + } label="Print continuous" /> + + + + ); +}; diff --git a/packages/tupaia-web/src/features/ExportSettings/DisplayOptionsSettings.tsx b/packages/tupaia-web/src/features/ExportSettings/DisplayOptionsSettings.tsx index 896a88c60f..0e41c797e1 100644 --- a/packages/tupaia-web/src/features/ExportSettings/DisplayOptionsSettings.tsx +++ b/packages/tupaia-web/src/features/ExportSettings/DisplayOptionsSettings.tsx @@ -29,6 +29,11 @@ const Group = styled(FormGroup)` } `; +const Wrapper = styled.div` + display: flex; + flex-direction: column; +`; + export const DisplayOptionsSettings = () => { const { exportWithLabels, @@ -41,28 +46,30 @@ export const DisplayOptionsSettings = () => { if (exportFormat !== ExportFormats.PNG) return null; return ( - - Display options - - - {!exportWithTableDisabled && ( + + + Display options + - )} - - + {!exportWithTableDisabled && ( + + )} + + + ); }; diff --git a/packages/tupaia-web/src/features/ExportSettings/ExportSettingsContext.tsx b/packages/tupaia-web/src/features/ExportSettings/ExportSettingsContext.tsx index 9f317e6d29..42930b6b0a 100644 --- a/packages/tupaia-web/src/features/ExportSettings/ExportSettingsContext.tsx +++ b/packages/tupaia-web/src/features/ExportSettings/ExportSettingsContext.tsx @@ -15,12 +15,14 @@ type ExportSettings = { exportWithLabels: boolean; exportWithTable: boolean; exportWithTableDisabled: boolean; + separatePagePerItem: boolean; }; type ExportSettingsContextType = ExportSettings & { setExportFormat: (value: ExportFormats) => void; setExportWithLabels: (value: boolean) => void; setExportWithTable: (value: boolean) => void; + setSeparatePagePerItem: (value: boolean) => void; }; const defaultContext = { @@ -28,9 +30,11 @@ const defaultContext = { exportWithLabels: false, exportWithTable: true, exportWithTableDisabled: false, + separatePagePerItem: true, setExportFormat: () => {}, setExportWithLabels: () => {}, setExportWithTable: () => {}, + setSeparatePagePerItem: () => {}, } as ExportSettingsContextType; // This is the context for the export settings @@ -45,6 +49,8 @@ export const useExportSettings = () => { setExportFormat, setExportWithLabels, setExportWithTable, + separatePagePerItem, + setSeparatePagePerItem, } = useContext(ExportSettingsContext); const updateExportFormat = (e: ChangeEvent) => @@ -58,10 +64,15 @@ export const useExportSettings = () => { setExportWithTable(e.target.checked); }; + const updateSeparatePagePerItem = (e: ChangeEvent) => { + setSeparatePagePerItem(e.target.value === 'true'); + }; + const resetExportSettings = (dashboardItemType?: string) => { setExportFormat(dashboardItemType === 'matrix' ? ExportFormats.XLSX : ExportFormats.PNG); setExportWithLabels(false); setExportWithTable(true); + setSeparatePagePerItem(true); }; return { @@ -73,6 +84,8 @@ export const useExportSettings = () => { updateExportWithLabels, updateExportWithTable, resetExportSettings, + separatePagePerItem, + updateSeparatePagePerItem, }; }; @@ -95,6 +108,11 @@ export const ExportSettingsContextProvider = ({ const [exportWithTableDisabled] = useState( defaultSettings?.exportWithTableDisabled || false, ); + + const [separatePagePerItem, setSeparatePagePerItem] = useState( + defaultSettings?.separatePagePerItem || true, + ); + return ( {children} diff --git a/packages/tupaia-web/src/features/ExportSettings/index.ts b/packages/tupaia-web/src/features/ExportSettings/index.ts index 21202d9802..060da73b0a 100644 --- a/packages/tupaia-web/src/features/ExportSettings/index.ts +++ b/packages/tupaia-web/src/features/ExportSettings/index.ts @@ -6,4 +6,5 @@ export { DisplayOptionsSettings } from './DisplayOptionsSettings'; export { ExportSettingLabel } from './ExportSettingLabel'; export { ExportFormatSettings } from './ExportFormatSettings'; +export { DisplayFormatSettings } from './DisplayFormatSettings'; export * from './ExportSettingsContext'; diff --git a/packages/tupaia-web/src/features/PDFExportDashboardItem/PDFExportDashboardItem.tsx b/packages/tupaia-web/src/features/PDFExportDashboardItem/PDFExportDashboardItem.tsx index 68ca534dc4..6130777633 100644 --- a/packages/tupaia-web/src/features/PDFExportDashboardItem/PDFExportDashboardItem.tsx +++ b/packages/tupaia-web/src/features/PDFExportDashboardItem/PDFExportDashboardItem.tsx @@ -32,11 +32,11 @@ const StyledA4Page = styled(A4Page)<{ }>` ${({ $isPreview, $previewZoom = 0.25 }) => $isPreview ? `width: 100%; zoom: ${$previewZoom};` : ''}; + padding-block-start: 0; + padding-block-end: 1cm; `; -const PDFExportBody = styled.main` - margin-block: 36pt; -`; +const PDFExportBody = styled.main``; const Title = styled.h3` font-size: 1.25rem; @@ -100,6 +100,7 @@ interface PDFExportDashboardItemProps { activeDashboard?: Dashboard; isPreview?: boolean; settings?: TupaiaWebExportDashboardRequest.ReqBody['settings']; + displayHeader?: boolean; } /** @@ -112,6 +113,7 @@ export const PDFExportDashboardItem = ({ activeDashboard, isPreview = false, settings, + displayHeader, }: PDFExportDashboardItemProps) => { const [width, setWidth] = useState(0); const pageRef = useRef(null); @@ -144,13 +146,14 @@ export const PDFExportDashboardItem = ({ const { config = {} as DashboardItemConfig } = dashboardItem || ({} as DashboardItem); + const { separatePagePerItem, ...restOfSettings } = settings || {}; const presentationOptions = config && 'presentationOptions' in config ? config.presentationOptions : undefined; const dashboardItemConfig = { ...config, presentationOptions: { ...presentationOptions, - ...settings, + ...restOfSettings, }, } as DashboardItemConfig; const { description, entityHeader, name, periodGranularity, reference } = dashboardItemConfig; @@ -163,18 +166,22 @@ export const PDFExportDashboardItem = ({ const period = getDatesAsString(periodGranularity, startDate, endDate); const data = isLoading ? undefined : (report as BaseReport)?.data; + return ( - - {entityName} - + {displayHeader && ( + + {entityName} + + )} - {activeDashboard?.name} + {displayHeader && {activeDashboard?.name}} {title} {reference && } {period && {period}} diff --git a/packages/tupaia-web/src/features/PDFExportDashboardItem/PDFExportHeader.tsx b/packages/tupaia-web/src/features/PDFExportDashboardItem/PDFExportHeader.tsx index e1eb4e56a4..86e7d552b3 100644 --- a/packages/tupaia-web/src/features/PDFExportDashboardItem/PDFExportHeader.tsx +++ b/packages/tupaia-web/src/features/PDFExportDashboardItem/PDFExportHeader.tsx @@ -16,7 +16,7 @@ const Container = styled.div` const HeaderImage = styled.img` aspect-ratio: 1; - height: 3.5cm; // equivalent to 132px + height: 3.5cm; object-fit: contain; `; diff --git a/packages/tupaia-web/src/features/QRCodeScanner/QRCodeScanner.tsx b/packages/tupaia-web/src/features/QRCodeScanner/QRCodeScanner.tsx new file mode 100644 index 0000000000..cc5fdb27f9 --- /dev/null +++ b/packages/tupaia-web/src/features/QRCodeScanner/QRCodeScanner.tsx @@ -0,0 +1,150 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { Button, IconButton, SmallAlert } from '@tupaia/ui-components'; +import { QrReader } from 'react-qr-reader'; +import { get } from '../../api'; +// This import is the actual type that QrReader uses +import { Result } from '@zxing/library'; +import { QRScanIcon } from './QRScanIcon'; +import { ClickAwayListener, Typography } from '@material-ui/core'; +import { Close } from '@material-ui/icons'; +import { generatePath, useLocation, useNavigate, useParams } from 'react-router'; +import { ROUTE_STRUCTURE } from '../../constants'; + +const QRScanButton = styled(Button).attrs({ + startIcon: , + variant: 'text', +})` + background: ${({ theme }) => theme.palette.background.paper}; + text-transform: none; + font-size: 0.875rem; + font-weight: 400; + padding-inline: 0.5rem; + white-space: nowrap; + height: 100%; +`; + +const ScannerWrapper = styled.div` + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 100vw; + z-index: 10; + background: ${({ theme }) => theme.palette.background.paper}; + padding-inline: 1.4rem; + padding-block: 1.2rem; +`; + +const CloseButton = styled(IconButton)` + .MuiSvgIcon-root { + font-size: 1rem; + } + padding: 0.5rem; +`; + +const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const Title = styled(Typography).attrs({ + variant: 'h1', +})` + font-size: 0.75rem; + font-weight: 500; +`; + +export const QRCodeScanner = ({ onCloseEntitySearch }: { onCloseEntitySearch: () => void }) => { + const { projectCode, dashboardName } = useParams(); + const navigate = useNavigate(); + const location = useLocation(); + const [isQRScannerOpen, setIsQRScannerOpen] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const toggleQRScanner = () => { + setIsQRScannerOpen(!isQRScannerOpen); + setErrorMessage(null); + }; + + const handleScan = async (data?: Result | null, error?: Error | null) => { + if (error?.message) { + setErrorMessage(error.message); + } + + if (!data) { + return; + } + + const text = data.getText(); + const entityId = text.replace('entity-', ''); + let entityCode: string; + + try { + const results = await get(`entities/${projectCode}/${projectCode}`, { + params: { + filter: { id: entityId }, + fields: ['code'], + }, + }); + const entity = results[0] ?? null; + + if (!entity) { + setErrorMessage( + 'No matching entity found in selected project. Please try another QR code, or check your project selection.', + ); + return; + } + entityCode = entity.code; + // reset error message + setErrorMessage(null); + } catch (e) { + setErrorMessage('Error fetching entity details. Please refresh the page and try again.'); + return; + } + + const path = generatePath(ROUTE_STRUCTURE, { + projectCode, + dashboardName, + entityCode, + }); + + // navigate to the entity page and close the scanner and entity search + navigate({ + ...location, + pathname: path, + }); + + setIsQRScannerOpen(false); + onCloseEntitySearch(); + }; + + return ( + <> + Scan ID + {isQRScannerOpen && ( + + +
+ Scan the location ID QR code using your camera + + + +
+ {errorMessage && {errorMessage}} + +
+
+ )} + + ); +}; diff --git a/packages/tupaia-web/src/features/QRCodeScanner/QRScanIcon.tsx b/packages/tupaia-web/src/features/QRCodeScanner/QRScanIcon.tsx new file mode 100644 index 0000000000..a692e5bd73 --- /dev/null +++ b/packages/tupaia-web/src/features/QRCodeScanner/QRScanIcon.tsx @@ -0,0 +1,24 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import { SvgIcon, SvgIconProps } from '@material-ui/core'; + +export const QRScanIcon = (props: SvgIconProps) => { + return ( + + + + ); +}; diff --git a/packages/tupaia-web/src/features/QRCodeScanner/QrCodeButton.tsx b/packages/tupaia-web/src/features/QRCodeScanner/QrCodeButton.tsx new file mode 100644 index 0000000000..001f322777 --- /dev/null +++ b/packages/tupaia-web/src/features/QRCodeScanner/QrCodeButton.tsx @@ -0,0 +1,21 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import styled from 'styled-components'; +import { Button } from '@tupaia/ui-components'; +import { QRScanIcon } from './QRScanIcon'; + +export const QrScanButton = styled(Button).attrs({ + startIcon: , + variant: 'text', +})` + background: ${({ theme }) => theme.palette.background.paper}; + text-transform: none; + font-size: 0.875rem; + font-weight: 400; + padding-inline: 0.5rem; + white-space: nowrap; + height: 100%; +`; diff --git a/packages/tupaia-web/src/features/QRCodeScanner/index.ts b/packages/tupaia-web/src/features/QRCodeScanner/index.ts new file mode 100644 index 0000000000..ff8e6eb8ae --- /dev/null +++ b/packages/tupaia-web/src/features/QRCodeScanner/index.ts @@ -0,0 +1,5 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +export { QRCodeScanner } from './QRCodeScanner'; diff --git a/packages/tupaia-web/src/features/Visuals/Chart.tsx b/packages/tupaia-web/src/features/Visuals/Chart.tsx index 68c318f85f..0ae2f9fb2a 100644 --- a/packages/tupaia-web/src/features/Visuals/Chart.tsx +++ b/packages/tupaia-web/src/features/Visuals/Chart.tsx @@ -140,10 +140,11 @@ const ContentWrapper = styled.div<{ padding: ${({ $isEnlarged }) => ($isEnlarged ? '1rem 0' : 'initial')}; height: ${({ $isExporting }) => $isExporting ? 'auto' : '15rem'}; // to stop charts from shrinking to nothing at mobile size - min-height: ${({ $isEnlarged }) => - $isEnlarged - ? '24rem' - : '0'}; // so that the chart table doesn't shrink the modal size when opened, of doesn't have much data + min-height: ${({ $isEnlarged, $isExporting }) => { + if ($isExporting) return '5rem'; // mainly for the 'no data' message + if ($isEnlarged) return '24rem'; + return 0; // so that the chart table doesn't shrink the modal size when opened, of doesn't have much data + }}; ${A4Page} & { padding: 0; height: auto; diff --git a/packages/tupaia-web/src/layout/ProjectCardList/ProjectCard.tsx b/packages/tupaia-web/src/layout/ProjectCardList/ProjectCard.tsx index 890ef0b882..4d55be500d 100644 --- a/packages/tupaia-web/src/layout/ProjectCardList/ProjectCard.tsx +++ b/packages/tupaia-web/src/layout/ProjectCardList/ProjectCard.tsx @@ -3,14 +3,17 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ import React, { ComponentType } from 'react'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import Lock from '@material-ui/icons/Lock'; import Alarm from '@material-ui/icons/Alarm'; import { darken, lighten } from '@material-ui/core/styles'; import { Typography } from '@material-ui/core'; +import { Button as UIButton } from '@tupaia/ui-components'; import { SingleProject } from '../../types'; import { MODAL_ROUTES, MOBILE_BREAKPOINT } from '../../constants'; import { RouterButton } from '../../components'; +import { useNavigate } from 'react-router-dom'; +import { useEditUser } from '../../api/mutations'; const Card = styled.div` display: flex; @@ -35,10 +38,6 @@ const Card = styled.div` padding-left: 1.875rem; padding-right: 1.875rem; } - - button { - margin-top: auto; - } `; const LogoWrapper = styled.div` @@ -92,7 +91,7 @@ const TextWrapper = styled.div` height: 100%; `; -const BaseLink = styled(RouterButton)` +const ButtonStyles = css` background: ${({ theme }) => theme.palette.primary.main}; border: 1px solid ${({ theme }) => theme.palette.primary.main}; color: white; @@ -116,6 +115,14 @@ const BaseLink = styled(RouterButton)` } `; +const BaseLink = styled(RouterButton)` + ${ButtonStyles} +`; + +const Button = styled(UIButton)` + ${ButtonStyles} +`; + const OutlineLink = styled(BaseLink).attrs({ variant: 'outlined', })` @@ -162,11 +169,33 @@ export const ProjectPendingLink = () => ( ); -export const ProjectAllowedLink = ({ url, isLandingPage }: LinkProps) => ( - - View project - -); +type ProjectAllowedLinkProps = LinkProps & { + projectId: string; +}; + +export const ProjectAllowedLink = ({ projectId, url, isLandingPage }: ProjectAllowedLinkProps) => { + const navigate = useNavigate(); + const { mutate } = useEditUser(() => { + if (isLandingPage) { + window.open(url, '_blank'); + } else { + navigate(url); + } + }); + const handleSelectProject = () => { + mutate({ projectId }); + }; + return ( + + ); +}; interface ProjectCardProps extends Partial { ProjectButton: ComponentType; diff --git a/packages/tupaia-web/src/layout/ProjectCardList/ProjectCardList.tsx b/packages/tupaia-web/src/layout/ProjectCardList/ProjectCardList.tsx index da01ee6bcd..266d62436a 100644 --- a/packages/tupaia-web/src/layout/ProjectCardList/ProjectCardList.tsx +++ b/packages/tupaia-web/src/layout/ProjectCardList/ProjectCardList.tsx @@ -54,7 +54,6 @@ export const ProjectCardList = ({ .filter(project => { return project.names.includes(selectedCountry); }) - .sort((a, b) => a.name.localeCompare(b.name)) .map(project => ({ ...project, ActionButton: getActionButton(project), diff --git a/packages/tupaia-web/src/layout/UserMenu/DrawerMenu.tsx b/packages/tupaia-web/src/layout/UserMenu/DrawerMenu.tsx index 32f25e4ceb..e9bf1654ea 100644 --- a/packages/tupaia-web/src/layout/UserMenu/DrawerMenu.tsx +++ b/packages/tupaia-web/src/layout/UserMenu/DrawerMenu.tsx @@ -10,6 +10,8 @@ import { IconButton } from '@tupaia/ui-components'; import CloseIcon from '@material-ui/icons/Close'; import { MenuItem, MenuList } from './MenuList'; import { MOBILE_BREAKPOINT, MODAL_ROUTES } from '../../constants'; +import { User } from '../../types'; +import { RouterButton } from '../../components'; /** * DrawerMenu is a drawer menu used when the user is on a mobile device @@ -22,11 +24,11 @@ const Drawer = styled(MuiDrawer)` `; const MenuWrapper = styled.div` - padding: 0 1.5em; + padding: 0 1rem; li a, li button { font-size: 1.2rem; - padding: 0.8rem 0.5rem; + padding: 0.8rem 0.8rem; line-height: 1.4; text-align: left; width: 100%; @@ -42,29 +44,26 @@ const Username = styled.p<{ $secondaryColor?: string; }>` font-weight: 400; - margin: 0; + margin: 1rem 0 0 0; width: 100%; color: ${({ $secondaryColor }) => $secondaryColor}; opacity: 0.5; font-size: 1.2rem; - padding: 0.5rem 0.5em 0.3rem; `; -const MenuHeaderWrapper = styled.div` - padding: 0; -`; - -const MenuHeaderContainer = styled.div<{ +const MenuHeader = styled.div<{ $secondaryColor?: string; }>` display: flex; - justify-content: flex-end; - padding: 0.8em 0; - align-items: center; + padding: 0 0.8rem; + justify-content: space-between; + align-items: flex-start; border-bottom: 1px solid ${({ $secondaryColor }) => $secondaryColor}; color: ${({ $secondaryColor }) => $secondaryColor}; `; +const MenuHeaderContainer = styled.div``; + const MenuCloseIcon = styled(CloseIcon)<{ $secondaryColor?: string; }>` @@ -74,7 +73,32 @@ const MenuCloseIcon = styled(CloseIcon)<{ `; const MenuCloseButton = styled(IconButton)` - padding: 0; + padding: 1rem; + margin: 0 -1rem 0 0; +`; + +const ProjectButton = styled(RouterButton).attrs({ + variant: 'text', +})` + padding: 0.2rem 0.8rem 0.6rem; + margin-inline-start: -0.8rem; + text-decoration: none; + + .MuiButton-label { + text-decoration: none; + text-transform: none; + font-size: 0.875rem; + color: ${({ theme }) => theme.palette.text.primary}; + transition: color 0.2s; + } + + &:hover { + background: none; + .MuiButton-label { + color: ${({ theme }) => theme.palette.text.primary}; + text-decoration: underline; + } + } `; interface DrawerMenuProps { @@ -84,7 +108,7 @@ interface DrawerMenuProps { primaryColor?: string; secondaryColor?: string; isLoggedIn: boolean; - currentUserUsername?: string; + currentUser?: User; } export const DrawerMenu = ({ @@ -94,8 +118,10 @@ export const DrawerMenu = ({ primaryColor, secondaryColor, isLoggedIn, - currentUserUsername, + currentUser, }: DrawerMenuProps) => { + const currentUserUsername = currentUser?.userName; + const userProjectName = currentUser?.project?.name || 'Explore'; return ( - - + + {currentUserUsername && ( {currentUserUsername} )} - - - + {userProjectName && ( + { + onCloseMenu(); + }} + > + {userProjectName} + + )} - + + + + {/** If the user is not logged in, show the register and login buttons */} {!isLoggedIn && ( diff --git a/packages/tupaia-web/src/layout/UserMenu/UserInfo.tsx b/packages/tupaia-web/src/layout/UserMenu/UserInfo.tsx index bd6c0cd105..6bcb2bd514 100644 --- a/packages/tupaia-web/src/layout/UserMenu/UserInfo.tsx +++ b/packages/tupaia-web/src/layout/UserMenu/UserInfo.tsx @@ -6,8 +6,10 @@ import React from 'react'; import styled from 'styled-components'; import { LinkProps } from 'react-router-dom'; +import { Tooltip as UITooltip } from '@tupaia/ui-components'; import { MOBILE_BREAKPOINT, MODAL_ROUTES } from '../../constants'; import { RouterButton } from '../../components'; +import { User } from '../../types'; /** * UserInfo is a component that displays the user's name if user is logged in, or a register and sign in button if not set @@ -59,8 +61,37 @@ const SignInButton = styled(RouterButton).attrs({ padding-right: 1em; `; +const ProjectButton = styled(RouterButton).attrs({ + variant: 'text', +})` + padding-inline: 0.3rem; + + .MuiButton-label { + text-transform: none; + font-size: 0.875rem; + color: ${({ theme }) => theme.palette.text.secondary}; + line-height: 1.4; + transition: color 0.2s; + } + + &:hover { + background: none; + .MuiButton-label { + color: ${({ theme }) => theme.palette.text.primary}; + text-decoration: underline; + } + } +`; + +// Wrap the button in a to support the tooltip +const Tooltip = ({ children }) => ( + + {children} + +); + interface UserInfoProps { - currentUserUsername?: string; + user?: User; isLandingPage?: boolean; secondaryColor?: string; isLoggedIn?: boolean; @@ -69,16 +100,25 @@ interface UserInfoProps { /** * This is the username OR user buttons. These are only visible in desktop */ -export const UserInfo = ({ - currentUserUsername, - isLandingPage, - secondaryColor, - isLoggedIn, -}: UserInfoProps) => { - if (isLoggedIn) +export const UserInfo = ({ user, isLandingPage, secondaryColor, isLoggedIn }: UserInfoProps) => { + if (isLoggedIn) { + const userName = user?.userName; + const userProjectName = user?.project?.name || 'Explore'; return ( - {currentUserUsername} + + {userName} + {!isLandingPage ? ( + <> + {' '} + | + + {userProjectName} + + + ) : null} + ); + } return ( Register diff --git a/packages/tupaia-web/src/layout/UserMenu/UserMenu.tsx b/packages/tupaia-web/src/layout/UserMenu/UserMenu.tsx index 9f274f45f1..8ee8a02aa7 100644 --- a/packages/tupaia-web/src/layout/UserMenu/UserMenu.tsx +++ b/packages/tupaia-web/src/layout/UserMenu/UserMenu.tsx @@ -122,7 +122,7 @@ export const UserMenu = () => { { isLoggedIn={isLoggedIn} primaryColor={menuPrimaryColor} secondaryColor={menuSecondaryColor} - currentUserUsername={user?.name} + currentUser={user} > {menuItems} diff --git a/packages/tupaia-web/src/theme/theme.ts b/packages/tupaia-web/src/theme/theme.ts index 935ed94058..992642ab10 100644 --- a/packages/tupaia-web/src/theme/theme.ts +++ b/packages/tupaia-web/src/theme/theme.ts @@ -1,13 +1,23 @@ import { createMuiTheme } from '@material-ui/core'; +import { MOBILE_BREAKPOINT } from '../constants'; // MUI v4 doesn't support callbacks for theme overrides, so since these shades get used in multiple places, we need to define them here const LIGHT_BLACK = '#2e2f33'; const DARK_BLACK = '#202124'; const LIGHT_GREY = '#9BA0A6'; +const overMobileBreakpoint = `@media (min-width: ${MOBILE_BREAKPOINT})`; + export const theme = createMuiTheme({ typography: { fontSize: 16, // this needs to be 16 to correctly calculate the axis labels in recharts + h1: { + fontSize: '1rem', // page titles + fontWeight: 500, + [overMobileBreakpoint]: { + fontSize: '1.125rem', + }, + }, body1: { fontSize: '1rem', }, @@ -35,6 +45,7 @@ export const theme = createMuiTheme({ form: { border: '#d9d9d9', }, + tooltip: '#606368', overlaySelector: { overlayNameBackground: '#072849', menuBackground: '#203e5c', // Dark blue used for button and header background in mobile overlay selector, as well as the background of the menu list on desktop diff --git a/packages/tupaia-web/src/types/index.d.ts b/packages/tupaia-web/src/types/index.d.ts index 619664992e..e496aa8aef 100644 --- a/packages/tupaia-web/src/types/index.d.ts +++ b/packages/tupaia-web/src/types/index.d.ts @@ -6,4 +6,5 @@ export * from './types'; export * from './dashboard'; export * from './entity'; +export * from './user'; export * from './helpers'; diff --git a/packages/tupaia-web/src/types/material-ui.d.ts b/packages/tupaia-web/src/types/material-ui.d.ts index d8ddc4b6ff..9e65d59e29 100644 --- a/packages/tupaia-web/src/types/material-ui.d.ts +++ b/packages/tupaia-web/src/types/material-ui.d.ts @@ -1,6 +1,7 @@ import { PaletteOptions, Palette } from '@material-ui/core/styles/createPalette'; type CustomPalette = { + tooltip?: string; form?: { border: React.CSSProperties['color']; }; diff --git a/packages/tupaia-web/src/types/user.d.ts b/packages/tupaia-web/src/types/user.d.ts new file mode 100644 index 0000000000..c5db7b4498 --- /dev/null +++ b/packages/tupaia-web/src/types/user.d.ts @@ -0,0 +1,7 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { KeysToCamelCase, TupaiaWebUserRequest } from '@tupaia/types'; + +export type User = KeysToCamelCase; diff --git a/packages/tupaia-web/src/views/DashboardPDFExport.tsx b/packages/tupaia-web/src/views/DashboardPDFExport.tsx index 92a761f6de..22e905ce9d 100644 --- a/packages/tupaia-web/src/views/DashboardPDFExport.tsx +++ b/packages/tupaia-web/src/views/DashboardPDFExport.tsx @@ -42,7 +42,7 @@ export const DashboardPDFExport = ({ const { activeDashboard } = useDashboard(); const { data: entity } = useEntity(projectCode, entityCode); - const { exportWithLabels, exportWithTable } = useExportSettings(); + const { exportWithLabels, exportWithTable, separatePagePerItem } = useExportSettings(); if (!activeDashboard) return null; @@ -60,6 +60,7 @@ export const DashboardPDFExport = ({ JSON.parse(urlSettings) || { exportWithLabels, exportWithTable, + separatePagePerItem, } ); } @@ -67,6 +68,7 @@ export const DashboardPDFExport = ({ return { exportWithLabels, exportWithTable, + separatePagePerItem, }; }; @@ -85,7 +87,7 @@ export const DashboardPDFExport = ({ return ( - {dashboardItems?.map(dashboardItem => ( + {dashboardItems?.map((dashboardItem, i) => ( ))} diff --git a/packages/tupaia-web/src/views/LandingPage/MultiProjectLandingPage.tsx b/packages/tupaia-web/src/views/LandingPage/MultiProjectLandingPage.tsx index 80c0d718c2..60c06d72a3 100644 --- a/packages/tupaia-web/src/views/LandingPage/MultiProjectLandingPage.tsx +++ b/packages/tupaia-web/src/views/LandingPage/MultiProjectLandingPage.tsx @@ -15,6 +15,7 @@ import { ProjectAllowedLink, ProjectPendingLink, } from '../../layout'; + const ProjectsWrapper = styled.div` width: 100%; max-width: ${({ theme }) => theme.breakpoints.values.lg}px; @@ -82,9 +83,10 @@ export function MultiProjectLandingPage({ projects={projects} actions={{ [PROJECT_ACCESS_TYPES.ALLOWED]: ({ - project: { code, homeEntityCode, dashboardGroupName }, + project: { id, code, homeEntityCode, dashboardGroupName }, }) => ( div { + max-block-size: 28rem; + } + + .MuiDialogActions-root { + position: relative; + top: 1rem; + } +`; + +const projectSort = (a, b) => { + // Sort by hasAccess = true first + if (a.hasAccess !== b.hasAccess) { + return a.hasAccess ? -1 : 1; + } + + // Sort by hasPendingAccess = true second + if (a.hasPendingAccess !== b.hasPendingAccess) { + return a.hasPendingAccess ? -1 : 1; + } + + // Otherwise, sort alphabetically by name + return a.name.localeCompare(b.name); +}; + +export const ProjectSelectModal = () => { + const { data: userData } = useUser(); + const location = useLocation(); + const projectId = userData?.project?.id; + const { data: projects = [], isFetching } = useProjects(); + const { closeModal } = useModal(); + const navigate = useNavigate(); + + const onSelectProject = data => { + const { projectId } = data; + const project = projects.find(p => p.id === projectId); + const { code, homeEntityCode } = project; + const dashboardGroupName = project.dashboardGroupName + ? encodeURIComponent(project.dashboardGroupName) + : ''; + navigate(`/${code}/${homeEntityCode}/${dashboardGroupName}`); + }; + + const { mutate: onConfirm, isLoading: isConfirming } = useEditUser(onSelectProject); + + const onRequestAccess = (projectCode: string) => { + const searchParams = new URLSearchParams(location.search); + searchParams.set(URL_SEARCH_PARAMS.PROJECT, projectCode); + navigate( + { + ...location, + search: searchParams.toString(), + hash: MODAL_ROUTES.REQUEST_PROJECT_ACCESS, + }, + { + state: { + referrer: location, + }, + }, + ); + }; + + const sortedProjects = projects.sort(projectSort); + + return ( + + + + + + ); +}; diff --git a/packages/tupaia-web/src/views/ProjectsModal.tsx b/packages/tupaia-web/src/views/ProjectsModal.tsx index 9655409fd4..652cba07a3 100644 --- a/packages/tupaia-web/src/views/ProjectsModal.tsx +++ b/packages/tupaia-web/src/views/ProjectsModal.tsx @@ -169,7 +169,7 @@ export const ProjectsModal = () => { value: string; } | null>(null); const { closeModal } = useModal(); - const { data, isFetching } = useProjects(); + const { data: projects = [], isFetching } = useProjects(); const { isLoggedIn } = useUser(); const location = useLocation(); const { data: countries, isLoading } = useCountries(); @@ -220,14 +220,15 @@ export const ProjectsModal = () => { ( { const [urlSearchParams] = useSearchParams(); const params = useParams(); + const { closeModal } = useModal(); const { mutate: requestCountryAccess, @@ -48,7 +49,10 @@ export const RequestProjectAccessModal = () => { error: requestCountryAccessError, isSuccess, } = useRequestCountryAccess(); - const { hash, ...location } = useLocation(); + + const { hash, state, ...location } = useLocation() as Location & { + state: { referrer?: Location }; + }; const navigate = useNavigate(); const { isLandingPage } = useLandingPage(); const { isLoggedIn, isLoading: isLoadingUser, isFetching } = useUser(); @@ -102,15 +106,16 @@ export const RequestProjectAccessModal = () => { // show the error if the user is getting a 403 error when trying to access an entity, as this means they have been redirected here from the useEntity hook const showError = (isError && entityError.code === 403) || hasRequestCountryAccessError; - const getBaseCloseLocation = () => { + const getBaseBackLocation = () => { if (isLandingPage) return location; if (isReturningToProjects) { + const newHash = state?.referrer?.hash ? state?.referrer.hash : MODAL_ROUTES.PROJECTS; return { ...location, // if the user has access to the project in the background, then return to the project with the project modal open, otherwise return to the default url with the project modal open pathname: backgroundProject?.hasAccess ? location.pathname : DEFAULT_URL, - hash: MODAL_ROUTES.PROJECTS, + hash: newHash, }; } @@ -125,20 +130,24 @@ export const RequestProjectAccessModal = () => { }; }; - const getCloseLocation = () => { - const baseCloseLocation = getBaseCloseLocation(); + const getBackLocation = () => { + const baseBackLocation = getBaseBackLocation(); // return the base close location with the project search param removed return { - ...baseCloseLocation, + ...baseBackLocation, search: removeUrlSearchParams([URL_SEARCH_PARAMS.PROJECT]), } as Location; }; - const closeLocation = getCloseLocation(); + const backLocation = getBackLocation(); const onCloseModal = () => { gaEvent('User', 'Close Dialog'); - navigate(closeLocation); + closeModal(); + }; + + const onBack = () => { + navigate(backLocation); }; return ( @@ -151,8 +160,8 @@ export const RequestProjectAccessModal = () => { isSubmitting={isSubmitting} onSubmit={requestCountryAccess} isSuccess={isSuccess} - onClose={onCloseModal} - closeButtonText={isReturningToProjects ? 'Return to projects' : 'Close'} + onBack={onBack} + backButtonText={isReturningToProjects ? 'Back to projects' : 'Close'} errorMessage={showError ? error.message : undefined} /> diff --git a/packages/tupaia-web/src/views/index.ts b/packages/tupaia-web/src/views/index.ts index c1b6549b68..3a07c55972 100644 --- a/packages/tupaia-web/src/views/index.ts +++ b/packages/tupaia-web/src/views/index.ts @@ -15,3 +15,4 @@ export { VerifyEmailResendModal } from './VerifyEmailResendModal'; export { DashboardPDFExport } from './DashboardPDFExport'; export { Unsubscribe } from './Unsubscribe'; export { MapOverlayPDFExport } from './MapOverlayPDFExport'; +export { ProjectSelectModal } from './ProjectSelectModal'; diff --git a/packages/types/src/schemas/schemas.ts b/packages/types/src/schemas/schemas.ts index 2e15614c2b..be16a87713 100644 --- a/packages/types/src/schemas/schemas.ts +++ b/packages/types/src/schemas/schemas.ts @@ -81284,6 +81284,10 @@ export const MeditrakDeviceSchema = { "install_id": { "type": "string" }, + "last_login": { + "type": "string", + "format": "date-time" + }, "platform": { "type": "string" }, @@ -81312,6 +81316,10 @@ export const MeditrakDeviceCreateSchema = { "install_id": { "type": "string" }, + "last_login": { + "type": "string", + "format": "date-time" + }, "platform": { "type": "string" }, @@ -81342,6 +81350,10 @@ export const MeditrakDeviceUpdateSchema = { "install_id": { "type": "string" }, + "last_login": { + "type": "string", + "format": "date-time" + }, "platform": { "type": "string" }, diff --git a/packages/types/src/types/models.ts b/packages/types/src/types/models.ts index 4126baeaf6..5b8da8ba2b 100644 --- a/packages/types/src/types/models.ts +++ b/packages/types/src/types/models.ts @@ -991,6 +991,7 @@ export interface MeditrakDevice { 'config'?: {} | null; 'id': string; 'install_id': string; + 'last_login'?: Date | null; 'platform'?: string | null; 'user_id': string; } @@ -998,6 +999,7 @@ export interface MeditrakDeviceCreate { 'app_version'?: string | null; 'config'?: {} | null; 'install_id': string; + 'last_login'?: Date | null; 'platform'?: string | null; 'user_id': string; } @@ -1006,6 +1008,7 @@ export interface MeditrakDeviceUpdate { 'config'?: {} | null; 'id'?: string; 'install_id'?: string; + 'last_login'?: Date | null; 'platform'?: string | null; 'user_id'?: string; } diff --git a/packages/types/src/types/requests/tupaia-web-server/EmailDashboardRequest.ts b/packages/types/src/types/requests/tupaia-web-server/EmailDashboardRequest.ts index 04aea75578..584cea6438 100644 --- a/packages/types/src/types/requests/tupaia-web-server/EmailDashboardRequest.ts +++ b/packages/types/src/types/requests/tupaia-web-server/EmailDashboardRequest.ts @@ -18,6 +18,7 @@ export type ReqBody = { settings?: { exportWithTable: boolean; exportWithLabels: boolean; + separatePagePerItem: boolean; }; }; export type ReqQuery = Record; diff --git a/packages/types/src/types/requests/tupaia-web-server/ExportDashboardRequest.ts b/packages/types/src/types/requests/tupaia-web-server/ExportDashboardRequest.ts index 742e4c634e..2cfe92d2ce 100644 --- a/packages/types/src/types/requests/tupaia-web-server/ExportDashboardRequest.ts +++ b/packages/types/src/types/requests/tupaia-web-server/ExportDashboardRequest.ts @@ -20,6 +20,7 @@ export type ReqBody = { settings?: { exportWithTable: boolean; exportWithLabels: boolean; + separatePagePerItem: boolean; }; }; export type ReqQuery = Record; diff --git a/packages/types/src/types/requests/tupaia-web-server/UserRequest.ts b/packages/types/src/types/requests/tupaia-web-server/UserRequest.ts index f46e5dd6da..b8f27cd721 100644 --- a/packages/types/src/types/requests/tupaia-web-server/UserRequest.ts +++ b/packages/types/src/types/requests/tupaia-web-server/UserRequest.ts @@ -7,6 +7,13 @@ export type Params = Record; export interface ResBody { userName?: string; email?: string; -}; + project?: { + id: string; + code: string; + name: string; + homeEntityCode: string; + dashboardGroupName: string; + }; +} export type ReqBody = Record; export type ReqQuery = Record; diff --git a/packages/ui-components/src/components/PDFExportComponent.tsx b/packages/ui-components/src/components/PDFExportComponent.tsx index b7f470a57a..3a5ce8f969 100644 --- a/packages/ui-components/src/components/PDFExportComponent.tsx +++ b/packages/ui-components/src/components/PDFExportComponent.tsx @@ -16,10 +16,12 @@ export const A4_PAGE_HEIGHT_MM = 297; export const A4_PAGE_WIDTH_PX = 1191; // at 144ppi export const A4_PAGE_HEIGHT_PX = 1683; // at 144ppi -export const A4Page = styled.div` +export const A4Page = styled.div<{ + separatePage?: boolean; +}>` width: ${A4_PAGE_WIDTH_PX}px; - - break-after: page; + page-break-after: ${({ separatePage }) => (separatePage ? 'always' : 'auto')}; + break-inside: avoid; flex-direction: column; padding: 1.5cm 4.5cm 2cm; // Bottom slightly taller than top for *optical* alignment `; diff --git a/packages/datatrak-web/src/components/SelectList/List.tsx b/packages/ui-components/src/components/SelectList/List.tsx similarity index 79% rename from packages/datatrak-web/src/components/SelectList/List.tsx rename to packages/ui-components/src/components/SelectList/List.tsx index d988ae33db..ea1ec55f13 100644 --- a/packages/datatrak-web/src/components/SelectList/List.tsx +++ b/packages/ui-components/src/components/SelectList/List.tsx @@ -1,11 +1,12 @@ /* * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import React from 'react'; import { List as MuiList } from '@material-ui/core'; -import { ListItem as Item, ListItemType } from './ListItem'; +import { ListItem as Item } from './ListItem'; +import { ListItemType } from './types'; interface SelectListProps { items?: ListItemType[]; diff --git a/packages/datatrak-web/src/components/SelectList/ListItem.tsx b/packages/ui-components/src/components/SelectList/ListItem.tsx similarity index 84% rename from packages/datatrak-web/src/components/SelectList/ListItem.tsx rename to packages/ui-components/src/components/SelectList/ListItem.tsx index eb90aeee69..09e4046cde 100644 --- a/packages/datatrak-web/src/components/SelectList/ListItem.tsx +++ b/packages/ui-components/src/components/SelectList/ListItem.tsx @@ -1,6 +1,6 @@ /* * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import React, { ReactElement, ReactNode, useState } from 'react'; @@ -11,9 +11,10 @@ import { ListItemProps as MuiListItemProps, } from '@material-ui/core'; import { Check, KeyboardArrowRight } from '@material-ui/icons'; -import { Tooltip } from '@tupaia/ui-components'; +import { Tooltip } from '../Tooltip'; +import { ListItemType } from './types'; -// explicity set the types so that the overrides are applied, for the `button` prop +// explicitly set the types so that the overrides are applied, for the `button` prop export const BaseListItem = styled(MuiListItem)` display: flex; align-items: center; @@ -30,9 +31,12 @@ export const BaseListItem = styled(MuiListItem)` &.MuiButtonBase-root { &:hover, &.Mui-selected:hover, - &:focus-visible, - &.Mui-selected:focus-visible { - background-color: ${({ theme }) => theme.palette.primary.main}33; + &:focus, + &.Mui-selected:focus { + background-color: ${({ theme }) => + theme.palette.type === 'light' + ? `${theme.palette.primary.main}33` + : 'rgba(96, 99, 104, 0.50)'}; } } .MuiSvgIcon-root { @@ -74,17 +78,6 @@ const IconWrapper = styled.div` } `; -export type ListItemType = Record & { - children?: ListItemType[]; - content: string | ReactNode; - value: string; - selected?: boolean; - icon?: ReactNode; - tooltip?: string; - button?: boolean; - disabled?: boolean; -}; - interface ListItemProps { item: ListItemType; children?: ReactNode; @@ -126,6 +119,7 @@ export const ListItem = ({ item, children, onSelect }: ListItemProps) => { return (
  • + {/*@ts-ignore*/} ` margin-bottom: 1rem; font-size: 0.875rem; - font-weight: 400; color: ${({ theme, color }) => theme.palette.text[color!]}; + font-weight: 400; `; + interface SelectListProps { items?: ListItemType[]; onSelect: (item: ListItemType) => void; diff --git a/packages/ui-components/src/components/SelectList/index.ts b/packages/ui-components/src/components/SelectList/index.ts new file mode 100644 index 0000000000..fec041cb2e --- /dev/null +++ b/packages/ui-components/src/components/SelectList/index.ts @@ -0,0 +1,8 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { List } from './List'; +export { ListItem } from './ListItem'; +export { SelectList } from './SelectList'; diff --git a/packages/ui-components/src/components/SelectList/types.ts b/packages/ui-components/src/components/SelectList/types.ts new file mode 100644 index 0000000000..5daa477631 --- /dev/null +++ b/packages/ui-components/src/components/SelectList/types.ts @@ -0,0 +1,18 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React, { ReactNode } from 'react'; +import { FormLabelProps } from '@material-ui/core'; + +export type ListItemType = Record & { + children?: ListItemType[]; + content: string | ReactNode; + value: string; + selected?: boolean; + icon?: ReactNode; + tooltip?: string; + button?: boolean; + disabled?: boolean; +}; diff --git a/packages/ui-components/src/components/Tooltip.tsx b/packages/ui-components/src/components/Tooltip.tsx index 522b7bd3e8..3e30ec0843 100644 --- a/packages/ui-components/src/components/Tooltip.tsx +++ b/packages/ui-components/src/components/Tooltip.tsx @@ -27,7 +27,7 @@ export const Tooltip = styled( ), )` & .MuiTooltip-tooltip { - background-color: ${TOOLTIP_COLOR}; + background-color: ${({ theme }) => theme.palette.tooltip || TOOLTIP_COLOR}; color: white; border-radius: 3px; font-size: 0.75rem; @@ -36,7 +36,7 @@ export const Tooltip = styled( letter-spacing: 0.4px; .MuiTooltip-arrow { - color: ${TOOLTIP_COLOR}; + color: ${({ theme }) => theme.palette.tooltip || TOOLTIP_COLOR}; } } `; diff --git a/packages/ui-components/src/components/index.ts b/packages/ui-components/src/components/index.ts index 6846547476..ace37eddb3 100644 --- a/packages/ui-components/src/components/index.ts +++ b/packages/ui-components/src/components/index.ts @@ -44,3 +44,4 @@ export * from './Tooltip'; export * from './UserMessage'; export * from './TooltipIconButton'; export * from './Pagination'; +export * from './SelectList'; diff --git a/packages/datatrak-web/src/features/ProjectSelectForm.tsx b/packages/ui-components/src/features/ProjectSelectForm.tsx similarity index 68% rename from packages/datatrak-web/src/features/ProjectSelectForm.tsx rename to packages/ui-components/src/features/ProjectSelectForm.tsx index e073e3ffcf..36fda7828f 100644 --- a/packages/datatrak-web/src/features/ProjectSelectForm.tsx +++ b/packages/ui-components/src/features/ProjectSelectForm.tsx @@ -3,16 +3,18 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import React, { ReactNode, useState } from 'react'; import styled from 'styled-components'; -import { DialogActions, Typography } from '@material-ui/core'; +import { KeysToCamelCase, Entity, Project as ProjectT } from '@tupaia/types'; +import { DialogActions, Typography, useTheme } from '@material-ui/core'; import { Lock as LockIcon, WatchLater as ClockIcon } from '@material-ui/icons'; -import { Button as UIButton, SpinningLoader } from '@tupaia/ui-components'; -import { Project } from '@tupaia/types'; -import { Button, SelectList } from '../components'; -import { useEditUser, useProjects } from '../api'; -import { ROUTES } from '../constants'; +import { SelectList, SpinningLoader, Button as UIButton } from '../components'; + +const Button = styled(UIButton)` + text-transform: none; + font-size: 0.875rem; + padding: 0.5rem 1.6rem; +`; const LoadingContainer = styled.div` display: flex; @@ -42,11 +44,35 @@ const ListWrapper = styled.div<{ } `; +const CancelButton = ({ onClick, children }: { onClick: () => void; children: ReactNode }) => { + const { palette } = useTheme(); + const variant = palette.type === 'light' ? 'outlined' : 'text'; + const color = palette.type === 'light' ? 'primary' : 'default'; + return ( + + ); +}; + +type Project = KeysToCamelCase & { + hasAccess: boolean; + hasPendingAccess: boolean; + homeEntityCode: Entity['code']; + name: Entity['name']; + names?: Entity['name'][]; + value?: string; +}; + interface ProjectSelectFormProps { projectId?: Project['id']; variant?: 'modal' | 'page'; onClose: () => void; - onRequestAccess?: (projectCode: Project['code']) => void; + onRequestAccess: (projectCode: Project['code']) => void; + projects?: Project[]; + isLoading: boolean; + onConfirm: (data: Record) => void; + isConfirming: boolean; } export const ProjectSelectForm = ({ @@ -54,36 +80,25 @@ export const ProjectSelectForm = ({ onClose, variant = 'page', onRequestAccess, + projects, + isLoading, + onConfirm, + isConfirming, }: ProjectSelectFormProps) => { - const navigate = useNavigate(); const [selectedProjectId, setSelectedProjectId] = useState(projectId); - const { data: projects, isLoading } = useProjects(); - const { mutate, isLoading: isConfirming } = useEditUser(onClose); - - const onConfirm = () => { - mutate({ projectId: selectedProjectId! }); - }; - - const handleRequestAccess = project => { - if (variant === 'modal' && onRequestAccess) { - onRequestAccess(project.code); - } else { - navigate({ - pathname: ROUTES.REQUEST_ACCESS, - search: `?project=${project?.code}`, - }); - } - }; - - const onSelect = project => { + const onSelect = (project: any) => { if (project.hasAccess) { setSelectedProjectId(project.value); } else { - handleRequestAccess(project); + onRequestAccess(project.code); } }; + const handleConfirm = () => { + onConfirm({ projectId: selectedProjectId! }); + }; + const getProjectIcon = (hasAccess: boolean, hasPendingAccess: boolean) => { if (hasPendingAccess) return ; if (!hasAccess) return ; @@ -133,13 +148,9 @@ export const ProjectSelectForm = ({ )} - {variant === 'modal' && ( - - Cancel - - )} + {variant === 'modal' && Cancel}