diff --git a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js index 567a1a7f90..9b3ceaf282 100644 --- a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js +++ b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js @@ -446,26 +446,45 @@ export const constructForSingle = (models, recordType) => { survey_id: [constructRecordExistsWithId(models.survey)], assignee_id: [constructIsEmptyOr(constructRecordExistsWithId(models.user))], due_date: [ - (value, { status }) => { - if (status !== 'repeating' && !value) { - throw new Error('Due date is required for non-recurring tasks'); + (value, { repeat_schedule: repeatSchedule }) => { + if (repeatSchedule) { + if (value) { + throw new Error('Recurring tasks must not have a due date'); + } + return true; } + if (!value) throw new Error('Due date is required for non-recurring tasks'); return true; }, ], repeat_schedule: [ - (value, { status }) => { - if (status === 'repeating' && !value) { - throw new Error('Repeat frequency is required for recurring tasks'); + (value, { due_date: dueDate }) => { + // If the task has a due date, the repeat schedule is empty + if (dueDate) { + if (value) { + throw new Error('Non-recurring tasks must not have a repeat schedule'); + } + return true; + } + + if (!value) { + throw new Error('Repeat schedule is required for recurring tasks'); } return true; }, ], status: [ (value, { repeat_schedule: repeatSchedule }) => { - if (repeatSchedule) return true; + // If the task is recurring, the status is empty + if (repeatSchedule) { + if (value) { + throw new Error('Recurring tasks cannot have a status'); + } + return true; + } + if (!value) { - throw new Error('Status is required'); + throw new Error('Status is required for non-recurring tasks'); } return true; }, diff --git a/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js b/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js index c7c79ea9a0..8add4378fe 100644 --- a/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js +++ b/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js @@ -47,7 +47,7 @@ describe('Permissions checker for CreateTask', async () => { const BASE_TASK = { assignee_id: assignee.id, - repeat_schedule: '{}', + repeat_schedule: null, due_date: new Date('2021-12-31'), status: 'to_do', }; diff --git a/packages/database/src/DatabaseModel.js b/packages/database/src/DatabaseModel.js index 02c355ded1..ea647b4bbe 100644 --- a/packages/database/src/DatabaseModel.js +++ b/packages/database/src/DatabaseModel.js @@ -78,7 +78,11 @@ export class DatabaseModel { if (!this.fieldNames) { const schema = await this.fetchSchema(); - this.fieldNames = Object.keys(schema); + const customColumnSelectors = this.customColumnSelectors || {}; + + this.fieldNames = [ + ...new Set([...Object.keys(schema), ...Object.keys(customColumnSelectors)]), + ]; } return this.fieldNames; } @@ -120,11 +124,12 @@ export class DatabaseModel { // Alias field names to the table to prevent errors when joining other tables // with same column names. const fieldNames = await this.fetchFieldNames(); + return fieldNames.map(fieldName => { const qualifiedName = this.fullyQualifyColumn(fieldName); - const customSelector = this.customColumnSelectors && this.customColumnSelectors[fieldName]; - if (customSelector) { - return { [fieldName]: customSelector(qualifiedName) }; + const customColumnSelector = this.getColumnSelector(fieldName, qualifiedName); + if (customColumnSelector) { + return { [fieldName]: customColumnSelector }; } return qualifiedName; }); @@ -148,6 +153,14 @@ export class DatabaseModel { return { ...options, ...customQueryOptions }; } + getColumnSelector(fieldName, qualifiedName) { + const customSelector = this.customColumnSelectors && this.customColumnSelectors[fieldName]; + if (customSelector) { + return customSelector(qualifiedName); + } + return null; + } + async getDbConditions(dbConditions = {}) { const fieldNames = await this.fetchFieldNames(); const fullyQualifiedConditions = {}; @@ -162,9 +175,15 @@ export class DatabaseModel { // Don't touch RAW conditions fullyQualifiedConditions[field] = value; } else { - const fullyQualifiedField = fieldNames.includes(field) - ? this.fullyQualifyColumn(field) - : field; + const qualifiedName = this.fullyQualifyColumn(field); + const customSelector = this.getColumnSelector(field, qualifiedName); + let fieldSelector = qualifiedName; + // if there is a custom selector, and it is a string, use it as the field selector. In some cases it will be an object, e.g. `castAs: 'text'` which is used to cast the field to a specific type, but this is not used as the field selector as an error will be thrown. + if (customSelector && typeof customSelector === 'string') { + fieldSelector = customSelector; + } + + const fullyQualifiedField = fieldNames.includes(field) ? fieldSelector : field; fullyQualifiedConditions[fullyQualifiedField] = value; } } diff --git a/packages/database/src/TupaiaDatabase.js b/packages/database/src/TupaiaDatabase.js index 18d2d4887c..d2b20802d6 100644 --- a/packages/database/src/TupaiaDatabase.js +++ b/packages/database/src/TupaiaDatabase.js @@ -514,6 +514,7 @@ export class TupaiaDatabase { */ function buildQuery(connection, queryConfig, where = {}, options = {}) { const { recordType, queryMethod, queryMethodParameter } = queryConfig; + let query = connection(recordType); // Query starts as just the table, but will be built up // If an innerQuery is defined, make the outer query wrap it @@ -676,6 +677,7 @@ function addWhereClause(connection, baseQuery, where) { } const columnKey = getColSelector(connection, key); + const columnSelector = castAs ? connection.raw(`??::${castAs}`, [columnKey]) : columnKey; const { args = [comparator, sanitizeComparisonValue(comparator, comparisonValue)] } = value; @@ -740,6 +742,7 @@ function getColSelector(connection, inputColStr) { return connection.raw(`COALESCE(${identifiers})`, bindings); } const casePattern = /^CASE/; + if (casePattern.test(inputColStr)) { return connection.raw(inputColStr); } diff --git a/packages/database/src/modelClasses/PermissionGroup.js b/packages/database/src/modelClasses/PermissionGroup.js index ae542f1588..033589a32f 100644 --- a/packages/database/src/modelClasses/PermissionGroup.js +++ b/packages/database/src/modelClasses/PermissionGroup.js @@ -34,6 +34,17 @@ export class PermissionGroupRecord extends DatabaseRecord { permissionGroupTree.map(treeItemFields => this.model.generateInstance(treeItemFields)), ); } + + async getAncestors() { + const permissionGroupTree = await this.model.database.findWithParents( + this.constructor.databaseRecord, + this.id, + ); + + return Promise.all( + permissionGroupTree.map(treeItemFields => this.model.generateInstance(treeItemFields)), + ); + } } export class PermissionGroupModel extends DatabaseModel { diff --git a/packages/database/src/modelClasses/User.js b/packages/database/src/modelClasses/User.js index 79a61e0ba4..fd87496f81 100644 --- a/packages/database/src/modelClasses/User.js +++ b/packages/database/src/modelClasses/User.js @@ -53,6 +53,14 @@ export class UserModel extends DatabaseModel { return user; } + customColumnSelectors = { + full_name: () => + `CASE + WHEN last_name IS NULL THEN first_name + ELSE first_name || ' ' || last_name + END`, + }; + emailVerifiedStatuses = { UNVERIFIED: 'unverified', VERIFIED: 'verified', diff --git a/packages/datatrak-web-server/examples.http b/packages/datatrak-web-server/examples.http index a30fb2dc92..3031017e47 100644 --- a/packages/datatrak-web-server/examples.http +++ b/packages/datatrak-web-server/examples.http @@ -52,3 +52,8 @@ content-type: {{contentType}} ### Get survey GET {{host}}/surveys/TAR HTTP/1.1 content-type: {{contentType}} + + +### Get survey users +GET {{host}}/users/TAR HTTP/1.1 +content-type: {{contentType}} diff --git a/packages/datatrak-web-server/src/app/createApp.ts b/packages/datatrak-web-server/src/app/createApp.ts index 8227e79a5c..dd87e04f20 100644 --- a/packages/datatrak-web-server/src/app/createApp.ts +++ b/packages/datatrak-web-server/src/app/createApp.ts @@ -47,6 +47,10 @@ import { GenerateLoginTokenRequest, TasksRequest, TasksRoute, + SurveyUsersRequest, + SurveyUsersRoute, + CreateTaskRequest, + CreateTaskRoute, } from '../routes'; import { attachAccessPolicy } from './middleware'; @@ -82,6 +86,8 @@ export async function createApp() { .get('activityFeed', handleWith(ActivityFeedRoute)) .get('tasks', handleWith(TasksRoute)) .get('surveyResponse/:id', handleWith(SingleSurveyResponseRoute)) + .get('users/:surveyCode/:countryCode', handleWith(SurveyUsersRoute)) + .post('tasks', handleWith(CreateTaskRoute)) // Forward auth requests to web-config .use('signup', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) .use('resendEmail', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) diff --git a/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts new file mode 100644 index 0000000000..87a7488ced --- /dev/null +++ b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts @@ -0,0 +1,68 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { DatatrakWebCreateTaskRequest, TaskStatus } from '@tupaia/types'; +import { stripTimezoneFromDate } from '@tupaia/utils'; + +export type CreateTaskRequest = Request< + DatatrakWebCreateTaskRequest.Params, + DatatrakWebCreateTaskRequest.ResBody, + DatatrakWebCreateTaskRequest.ReqBody, + DatatrakWebCreateTaskRequest.ReqQuery +>; + +export class CreateTaskRoute extends Route { + public async buildResponse() { + const { models, body, ctx } = this.req; + + const { surveyCode, entityId, assigneeId, dueDate, repeatSchedule } = body; + + const survey = await models.survey.findOne({ code: surveyCode }); + if (!survey) { + throw new Error('Survey not found'); + } + + const taskDetails: { + survey_id: string; + entity_id: string; + assignee_id?: string; + due_date?: string | null; + repeat_schedule?: string; + status?: TaskStatus; + } = { + survey_id: survey.id, + entity_id: entityId, + assignee_id: assigneeId, + }; + + if (repeatSchedule) { + // if task is repeating, clear due date + taskDetails.repeat_schedule = JSON.stringify({ + // TODO: format this correctly when recurring tasks are implemented + frequency: repeatSchedule, + }); + taskDetails.due_date = null; + } else { + if (!dueDate) { + throw new Error('Due date is required for non-repeating tasks'); + } + + // apply status and due date only if not a repeating task + // set due date to end of day + const endOfDay = new Date(new Date(dueDate).setHours(23, 59, 59, 999)); + + // strip timezone from date so that the returned date is always in the user's timezone + const withoutTimezone = stripTimezoneFromDate(endOfDay); + + taskDetails.due_date = withoutTimezone; + + taskDetails.status = TaskStatus.to_do; + } + + return ctx.services.central.createResource('tasks', {}, taskDetails); + } +} diff --git a/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts b/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts new file mode 100644 index 0000000000..d72b7812e1 --- /dev/null +++ b/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts @@ -0,0 +1,102 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { DatatrakWebSurveyUsersRequest, EntityType } from '@tupaia/types'; +import { QUERY_CONJUNCTIONS } from '@tupaia/database'; + +const USERS_EXCLUDED_FROM_LIST = [ + 'edmofro@gmail.com', // Edwin + 'kahlinda.mahoney@gmail.com', // Kahlinda + 'lparish1980@gmail.com', // Lewis + 'sus.lake@gmail.com', // Susie + 'michaelnunan@hotmail.com', // Michael + '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 + 'tamanu-server@tupaia.org', // Tamanu Server + 'public@tupaia.org', // Public User +]; + +export type SurveyUsersRequest = Request< + DatatrakWebSurveyUsersRequest.Params, + DatatrakWebSurveyUsersRequest.ResBody, + DatatrakWebSurveyUsersRequest.ReqBody, + DatatrakWebSurveyUsersRequest.ReqQuery +>; + +const DEFAULT_PAGE_SIZE = 100; + +export class SurveyUsersRoute extends Route { + public async buildResponse() { + const { models, params, query } = this.req; + const { surveyCode, countryCode } = params; + + const { searchTerm } = query; + + const survey = await models.survey.findOne({ code: surveyCode }); + + if (!survey) { + throw new Error(`Survey with code ${surveyCode} not found`); + } + + const { permission_group_id: permissionGroupId } = survey; + + if (!permissionGroupId) { + return []; + } + + // get the permission group + const permissionGroup = await models.permissionGroup.findById(permissionGroupId); + + if (!permissionGroup) { + throw new Error(`Permission group with id ${permissionGroupId} not found`); + } + + // get the ancestors of the permission group + const permissionGroupWithAncestors = await permissionGroup.getAncestors(); + + const entity = await models.entity.findOne({ + country_code: countryCode, + type: EntityType.country, + }); + + // get the user entity permissions for the permission group and its ancestors + const userEntityPermissions = await models.userEntityPermission.find({ + permission_group_id: permissionGroupWithAncestors.map(p => p.id), + entity_id: entity.id, + }); + + const userIds = userEntityPermissions.map(uep => uep.user_id); + + const usersFilter = { + id: userIds, + email: { comparator: 'not in', comparisonValue: USERS_EXCLUDED_FROM_LIST }, + [QUERY_CONJUNCTIONS.RAW]: { + // exclude E2E users and any internal users + sql: `(email NOT LIKE '%tupaia.org' AND email NOT LIKE '%beyondessential.com.au' AND email NOT LIKE '%@bes.au')`, + }, + } as Record; + + if (searchTerm) { + usersFilter.full_name = { comparator: 'ilike', comparisonValue: `${searchTerm}%` }; + } + + const users = await models.user.find(usersFilter, { + sort: ['full_name ASC'], + limit: DEFAULT_PAGE_SIZE, + }); + const userData = users.map(user => ({ + id: user.id, + name: user.full_name, + })); + + // only return the id and name of the users + return userData; + } +} diff --git a/packages/datatrak-web-server/src/routes/UserRoute.ts b/packages/datatrak-web-server/src/routes/UserRoute.ts index 42803ca0ce..5211769758 100644 --- a/packages/datatrak-web-server/src/routes/UserRoute.ts +++ b/packages/datatrak-web-server/src/routes/UserRoute.ts @@ -28,6 +28,7 @@ export class UserRoute extends Route { const { id, + full_name: fullName, first_name: firstName, last_name: lastName, email, @@ -57,7 +58,7 @@ export class UserRoute extends Route { } return { - userName: `${firstName} ${lastName}`, + fullName, firstName, lastName, email, diff --git a/packages/datatrak-web-server/src/routes/index.ts b/packages/datatrak-web-server/src/routes/index.ts index 5d52969cae..b76aa5a76f 100644 --- a/packages/datatrak-web-server/src/routes/index.ts +++ b/packages/datatrak-web-server/src/routes/index.ts @@ -22,3 +22,5 @@ export { ActivityFeedRequest, ActivityFeedRoute } from './ActivityFeedRoute'; export { EntitiesRequest, EntitiesRoute } from './EntitiesRoute'; export { GenerateLoginTokenRequest, GenerateLoginTokenRoute } from './GenerateLoginTokenRoute'; export { TasksRequest, TasksRoute } from './TasksRoute'; +export { SurveyUsersRequest, SurveyUsersRoute } from './SurveyUsersRoute'; +export { CreateTaskRequest, CreateTaskRoute } from './CreateTaskRoute'; diff --git a/packages/datatrak-web-server/src/types.ts b/packages/datatrak-web-server/src/types.ts index a0f22c169a..0081748abb 100644 --- a/packages/datatrak-web-server/src/types.ts +++ b/packages/datatrak-web-server/src/types.ts @@ -9,9 +9,11 @@ import { FeedItemModel, OneTimeLoginModel, OptionModel, + PermissionGroupModel, SurveyModel, SurveyResponseModel, TaskModel, + UserEntityPermissionModel, UserModel, } from '@tupaia/server-boilerplate'; @@ -25,4 +27,6 @@ export interface DatatrakWebServerModelRegistry extends ModelRegistry { readonly oneTimeLogin: OneTimeLoginModel; readonly option: OptionModel; readonly task: TaskModel; + readonly permissionGroup: PermissionGroupModel; + readonly userEntityPermission: UserEntityPermissionModel; } diff --git a/packages/datatrak-web/package.json b/packages/datatrak-web/package.json index be5744f9a8..8e03afbb16 100644 --- a/packages/datatrak-web/package.json +++ b/packages/datatrak-web/package.json @@ -14,6 +14,7 @@ "@material-ui/core": "^4.9.11", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.57", + "@material-ui/pickers": "^3.2.10", "@material-ui/styles": "^4.9.10", "@testing-library/react-hooks": "^8.0.1", "@tupaia/expression-parser": "workspace:*", diff --git a/packages/datatrak-web/public/tupaia-pin.svg b/packages/datatrak-web/public/tupaia-pin.svg index 17cc70a7fb..1cafdf59de 100644 --- a/packages/datatrak-web/public/tupaia-pin.svg +++ b/packages/datatrak-web/public/tupaia-pin.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/packages/datatrak-web/src/__tests__/features/Questions/EntityQuestion.test.tsx b/packages/datatrak-web/src/__tests__/features/Questions/EntityQuestion.test.tsx index fdc5c5013b..268e0c99a5 100644 --- a/packages/datatrak-web/src/__tests__/features/Questions/EntityQuestion.test.tsx +++ b/packages/datatrak-web/src/__tests__/features/Questions/EntityQuestion.test.tsx @@ -15,6 +15,10 @@ jest.mock('../../../features/Survey/SurveyContext/SurveyContext.tsx', () => ({ useSurveyForm: () => ({ getAnswerByQuestionId: () => 'blue', surveyProjectCode: 'explore', + formData: { + theParentQuestionId: 'blue', + theCodeQuestionId: 'blue', + }, }), })); diff --git a/packages/datatrak-web/src/api/mutations/index.ts b/packages/datatrak-web/src/api/mutations/index.ts index 7bd41ae67c..e89e81f267 100644 --- a/packages/datatrak-web/src/api/mutations/index.ts +++ b/packages/datatrak-web/src/api/mutations/index.ts @@ -16,3 +16,4 @@ export { useRequestDeleteAccount } from './useRequestDeleteAccount'; export { useOneTimeLogin } from './useOneTimeLogin'; export * from './useExportSurveyResponses'; export { useTupaiaRedirect } from './useTupaiaRedirect'; +export { useCreateTask } from './useCreateTask'; diff --git a/packages/datatrak-web/src/api/mutations/useCreateTask.ts b/packages/datatrak-web/src/api/mutations/useCreateTask.ts new file mode 100644 index 0000000000..4efb1485d6 --- /dev/null +++ b/packages/datatrak-web/src/api/mutations/useCreateTask.ts @@ -0,0 +1,25 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useMutation, useQueryClient } from 'react-query'; +import { DatatrakWebCreateTaskRequest } from '@tupaia/types'; +import { post } from '../api'; + +export const useCreateTask = (onSuccess?: () => void) => { + const queryClient = useQueryClient(); + return useMutation( + (data: DatatrakWebCreateTaskRequest.ReqBody) => { + return post('tasks', { + data, + }); + }, + { + onSuccess: () => { + queryClient.invalidateQueries('tasks'); + if (onSuccess) onSuccess(); + }, + }, + ); +}; diff --git a/packages/datatrak-web/src/api/mutations/useEditUser.ts b/packages/datatrak-web/src/api/mutations/useEditUser.ts index ce44980794..1ea167a0dc 100644 --- a/packages/datatrak-web/src/api/mutations/useEditUser.ts +++ b/packages/datatrak-web/src/api/mutations/useEditUser.ts @@ -45,6 +45,7 @@ export const useEditUser = (onSuccess?: () => void) => { // If the user changes their project, we need to invalidate the entity descendants query so that recent entities are updated if they change back to the previous project without refreshing the page if (variables.projectId) { queryClient.invalidateQueries('entityDescendants'); + queryClient.invalidateQueries('tasks'); } if (onSuccess) onSuccess(); }, diff --git a/packages/datatrak-web/src/api/queries/index.ts b/packages/datatrak-web/src/api/queries/index.ts index 499a76cb80..8bc25ddf34 100644 --- a/packages/datatrak-web/src/api/queries/index.ts +++ b/packages/datatrak-web/src/api/queries/index.ts @@ -21,3 +21,4 @@ export { useActivityFeed, useCurrentProjectActivityFeed } from './useActivityFee export { useProjectSurveys } from './useProjectSurveys'; export { useEntities } from './useEntities'; export { useTasks } from './useTasks'; +export { useSurveyUsers } from './useSurveyUsers'; diff --git a/packages/datatrak-web/src/api/queries/useProjectEntities.ts b/packages/datatrak-web/src/api/queries/useProjectEntities.ts index 7f6afde00e..ad5f1d13c5 100644 --- a/packages/datatrak-web/src/api/queries/useProjectEntities.ts +++ b/packages/datatrak-web/src/api/queries/useProjectEntities.ts @@ -9,6 +9,7 @@ import { get } from '../api'; export const useProjectEntities = ( projectCode?: string, params?: DatatrakWebEntityDescendantsRequest.ReqBody, + enabled = true, ) => { return useQuery( ['entityDescendants', projectCode, params], @@ -17,6 +18,6 @@ export const useProjectEntities = ( params: { ...params, filter: { ...params?.filter, projectCode } }, }); }, - { enabled: !!projectCode }, + { enabled: !!projectCode && enabled }, ); }; diff --git a/packages/datatrak-web/src/api/queries/useSurveyUsers.ts b/packages/datatrak-web/src/api/queries/useSurveyUsers.ts new file mode 100644 index 0000000000..d79a8819ea --- /dev/null +++ b/packages/datatrak-web/src/api/queries/useSurveyUsers.ts @@ -0,0 +1,28 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useQuery } from 'react-query'; +import { Country, DatatrakWebSurveyUsersRequest } from '@tupaia/types'; +import { get } from '../api'; +import { Survey } from '../../types'; + +export const useSurveyUsers = ( + surveyCode?: Survey['code'], + countryCode?: Country['code'], + searchTerm?: string, +) => { + return useQuery( + ['surveyUsers', surveyCode, countryCode, searchTerm], + (): Promise => + get(`users/${surveyCode}/${countryCode}`, { + params: { + searchTerm, + }, + }), + { + enabled: !!surveyCode && !!countryCode, + }, + ); +}; diff --git a/packages/datatrak-web/src/components/SelectList/SelectList.tsx b/packages/datatrak-web/src/components/SelectList/SelectList.tsx index 4c5232c16a..83c310e359 100644 --- a/packages/datatrak-web/src/components/SelectList/SelectList.tsx +++ b/packages/datatrak-web/src/components/SelectList/SelectList.tsx @@ -5,7 +5,7 @@ import React from 'react'; import styled, { css } from 'styled-components'; -import { FormLabel, Typography } from '@material-ui/core'; +import { FormLabel, FormLabelProps, Typography } from '@material-ui/core'; import { ListItemType } from './ListItem'; import { List } from './List'; @@ -46,13 +46,13 @@ const NoResultsMessage = styled(Typography)` color: ${({ theme }) => theme.palette.text.secondary}; `; -const Label = styled(FormLabel).attrs({ - component: 'h2', -})` +const Label = styled(FormLabel)<{ + component: React.ElementType; +}>` margin-bottom: 1rem; font-size: 0.875rem; - color: ${({ theme }) => theme.palette.text.secondary}; font-weight: 400; + color: ${({ theme, color }) => theme.palette.text[color!]}; `; interface SelectListProps { items?: ListItemType[]; @@ -60,6 +60,9 @@ interface SelectListProps { label?: string; ListItem?: React.ElementType; variant?: 'fullPage' | 'inline'; + labelProps?: FormLabelProps & { + component?: React.ElementType; + }; } export const SelectList = ({ @@ -68,11 +71,16 @@ export const SelectList = ({ label, ListItem, variant = 'inline', + labelProps = {}, }: SelectListProps) => { return ( - {label && } - + {label && ( + + )} + {items.length === 0 ? ( No items to display ) : ( diff --git a/packages/datatrak-web/src/views/SurveySelectPage/SurveyCountrySelector.tsx b/packages/datatrak-web/src/features/CountrySelector/CountrySelector.tsx similarity index 90% rename from packages/datatrak-web/src/views/SurveySelectPage/SurveyCountrySelector.tsx rename to packages/datatrak-web/src/features/CountrySelector/CountrySelector.tsx index 951bbd9be7..ee486e38b8 100644 --- a/packages/datatrak-web/src/views/SurveySelectPage/SurveyCountrySelector.tsx +++ b/packages/datatrak-web/src/features/CountrySelector/CountrySelector.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 from 'react'; import styled from 'styled-components'; @@ -42,17 +42,17 @@ const CountrySelectWrapper = styled.div` align-items: center; `; -interface SurveyCountrySelectorProps { +interface CountrySelectorProps { countries: Entity[]; selectedCountry?: Country | null; onChangeCountry: (country: Entity | null) => void; } -export const SurveyCountrySelector = ({ +export const CountrySelector = ({ countries, selectedCountry, onChangeCountry, -}: SurveyCountrySelectorProps) => { +}: CountrySelectorProps) => { const updateSelectedCountry = (e: React.ChangeEvent) => { onChangeCountry(countries.find(country => country.code === e.target.value) || null); }; diff --git a/packages/datatrak-web/src/features/CountrySelector/index.ts b/packages/datatrak-web/src/features/CountrySelector/index.ts new file mode 100644 index 0000000000..98527f6ffc --- /dev/null +++ b/packages/datatrak-web/src/features/CountrySelector/index.ts @@ -0,0 +1,7 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { useUserCountries } from './useUserCountries'; +export { CountrySelector } from './CountrySelector'; diff --git a/packages/datatrak-web/src/views/SurveySelectPage/useUserCountries.ts b/packages/datatrak-web/src/features/CountrySelector/useUserCountries.ts similarity index 96% rename from packages/datatrak-web/src/views/SurveySelectPage/useUserCountries.ts rename to packages/datatrak-web/src/features/CountrySelector/useUserCountries.ts index ee8441ca9a..477bee155d 100644 --- a/packages/datatrak-web/src/views/SurveySelectPage/useUserCountries.ts +++ b/packages/datatrak-web/src/features/CountrySelector/useUserCountries.ts @@ -1,6 +1,6 @@ /* * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import { useState } from 'react'; import { useProjectEntities, useCurrentUserContext } from '../../api'; diff --git a/packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx b/packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx new file mode 100644 index 0000000000..4b57afa8be --- /dev/null +++ b/packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx @@ -0,0 +1,173 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { useFormContext } from 'react-hook-form'; +import { FormHelperText, FormLabel, FormLabelProps, TypographyProps } from '@material-ui/core'; +import { Country, SurveyScreenComponentConfig } from '@tupaia/types'; +import { SpinningLoader, useDebounce } from '@tupaia/ui-components'; +import { useEntityById, useProjectEntities } from '../../api'; +import { ResultsList } from './ResultsList'; +import { SearchField } from './SearchField'; +import { useEntityBaseFilters } from './useEntityBaseFilters'; + +const Container = styled.div` + width: 100%; + z-index: 0; + + fieldset:disabled & { + pointer-events: none; + } +`; + +const Label = styled(FormLabel)` + font-size: 1rem; + cursor: pointer; +`; + +const useSearchResults = (searchValue, filter, projectCode, disableSearch = false) => { + const debouncedSearch = useDebounce(searchValue!, 200); + return useProjectEntities( + projectCode, + { + fields: ['id', 'parent_name', 'code', 'name', 'type'], + filter, + searchString: debouncedSearch, + }, + !disableSearch, + ); +}; + +interface EntitySelectorProps { + id: string; + label?: string | null; + detailLabel?: string | null; + name?: string | null; + required?: boolean | null; + controllerProps: { + onChange: (value: string) => void; + value: string; + ref: any; + invalid?: boolean; + }; + showLegend: boolean; + projectCode?: string; + showRecentEntities?: boolean; + config?: SurveyScreenComponentConfig | null; + data?: Record; + countryCode?: Country['code']; + disableSearch?: boolean; + isLoading?: boolean; + showSearchInput?: boolean; + legend?: string | null; + legendProps?: FormLabelProps & { + component?: React.ElementType; + variant?: TypographyProps['variant']; + }; +} + +export const EntitySelector = ({ + id, + label, + detailLabel, + name, + required, + controllerProps: { onChange, value, ref, invalid }, + projectCode, + showLegend, + showRecentEntities, + config, + data, + countryCode, + disableSearch, + isLoading, + showSearchInput, + legend, + legendProps, +}: EntitySelectorProps) => { + const { errors } = useFormContext(); + const [isDirty, setIsDirty] = useState(false); + const [searchValue, setSearchValue] = useState(''); + + // Display a previously selected value + useEntityById(value, { + staleTime: 0, // Needs to be 0 to make sure the entity is fetched on first render + enabled: !!value && !searchValue, + onSuccess: entityData => { + if (!isDirty) { + setSearchValue(entityData.name); + } + }, + }); + const onChangeSearch = newValue => { + setIsDirty(true); + setSearchValue(newValue); + }; + + const onSelect = entity => { + setIsDirty(true); + onChange(entity.value); + }; + + const filters = useEntityBaseFilters(config, data, countryCode); + + const { + data: searchResults, + isLoading: isLoadingSearchResults, + isFetched, + } = useSearchResults(searchValue, filters, projectCode, disableSearch); + + const displayResults = searchResults?.filter(({ name: entityName }) => { + if (isDirty || !value) { + return true; + } + return entityName === searchValue; + }); + + const showLoader = isLoading || ((isLoadingSearchResults || !isFetched) && !disableSearch); + + return ( + <> + + {showLegend && ( + + )} +
+ {showSearchInput && ( + + )} + {showLoader ? ( + + ) : ( + + )} +
+ + {errors && errors[name!] && {errors[name!].message}} + + ); +}; diff --git a/packages/datatrak-web/src/features/Questions/EntityQuestion/ResultsList.tsx b/packages/datatrak-web/src/features/EntitySelector/ResultsList.tsx similarity index 58% rename from packages/datatrak-web/src/features/Questions/EntityQuestion/ResultsList.tsx rename to packages/datatrak-web/src/features/EntitySelector/ResultsList.tsx index 2e824d9abd..952af1a30a 100644 --- a/packages/datatrak-web/src/features/Questions/EntityQuestion/ResultsList.tsx +++ b/packages/datatrak-web/src/features/EntitySelector/ResultsList.tsx @@ -7,13 +7,19 @@ import React from 'react'; import styled from 'styled-components'; import { Typography } from '@material-ui/core'; import RoomIcon from '@material-ui/icons/Room'; -import { SelectList } from '../../../components'; +import { DatatrakWebEntityDescendantsRequest } from '@tupaia/types'; +import { ListItemType, SelectList } from '../../components'; + +const DARK_BLUE = '#004975'; const ListWrapper = styled.div` display: flex; flex-direction: column; overflow: auto; margin-top: 0.9rem; + li .MuiSvgIcon-root { + color: ${DARK_BLUE}; + } `; const SubListWrapper = styled.div` @@ -37,21 +43,36 @@ export const ResultItem = ({ name, parentName }) => { ); }; -export const ResultsList = ({ value, searchResults, onSelect }) => { +type SearchResults = DatatrakWebEntityDescendantsRequest.ResBody; +interface ResultsListProps { + value: string; + searchResults?: SearchResults; + onSelect: (value: ListItemType) => void; + showRecentEntities?: boolean; +} + +export const ResultsList = ({ + value, + searchResults, + onSelect, + showRecentEntities, +}: ResultsListProps) => { const getEntitiesList = (returnRecentEntities?: boolean) => { const entities = searchResults?.filter(({ isRecent }) => returnRecentEntities ? isRecent : !isRecent, ); - return entities?.map(({ name, parentName, code, id }) => ({ - content: , - value: id, - code, - selected: id === value, - icon: , - button: true, - })); + return ( + entities?.map(({ name, parentName, code, id }) => ({ + content: , + value: id, + code, + selected: id === value, + icon: , + button: true, + })) ?? [] + ); }; - const recentEntities = getEntitiesList(true); + const recentEntities = showRecentEntities ? getEntitiesList(true) : []; const displayResults = getEntitiesList(false); return ( @@ -63,7 +84,7 @@ export const ResultsList = ({ value, searchResults, onSelect }) => { )} - All entities + {showRecentEntities && All entities} diff --git a/packages/datatrak-web/src/features/Questions/EntityQuestion/SearchField.tsx b/packages/datatrak-web/src/features/EntitySelector/SearchField.tsx similarity index 88% rename from packages/datatrak-web/src/features/Questions/EntityQuestion/SearchField.tsx rename to packages/datatrak-web/src/features/EntitySelector/SearchField.tsx index 71d53551fe..65a7153fe2 100644 --- a/packages/datatrak-web/src/features/Questions/EntityQuestion/SearchField.tsx +++ b/packages/datatrak-web/src/features/EntitySelector/SearchField.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 from 'react'; @@ -8,7 +8,7 @@ import { TextField } from '@tupaia/ui-components'; import styled from 'styled-components'; import { Search, Clear } from '@material-ui/icons'; import { InputAdornment, IconButton, TextFieldProps } from '@material-ui/core'; -import { InputHelperText } from '../../../components'; +import { InputHelperText } from '../../components'; const StyledField = styled(TextField)` margin-bottom: 0; @@ -64,8 +64,18 @@ type SearchFieldProps = TextFieldProps & { }; export const SearchField = React.forwardRef((props, ref) => { - const { name, label, id, searchValue, onChangeSearch, isDirty, invalid, detailLabel, required } = - props; + const { + name, + label, + id, + searchValue, + onChangeSearch, + isDirty, + invalid, + detailLabel, + required, + inputProps, + } = props; const displayValue = isDirty ? searchValue : ''; @@ -109,6 +119,7 @@ export const SearchField = React.forwardRef((p ) : null, }} + inputProps={inputProps} /> ); }); diff --git a/packages/datatrak-web/src/features/EntitySelector/index.ts b/packages/datatrak-web/src/features/EntitySelector/index.ts new file mode 100644 index 0000000000..92d0ab3cf8 --- /dev/null +++ b/packages/datatrak-web/src/features/EntitySelector/index.ts @@ -0,0 +1,5 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +export { EntitySelector } from './EntitySelector'; diff --git a/packages/datatrak-web/src/features/Questions/EntityQuestion/utils.ts b/packages/datatrak-web/src/features/EntitySelector/useEntityBaseFilters.ts similarity index 50% rename from packages/datatrak-web/src/features/Questions/EntityQuestion/utils.ts rename to packages/datatrak-web/src/features/EntitySelector/useEntityBaseFilters.ts index 3ec4631a61..aaecfd1c0c 100644 --- a/packages/datatrak-web/src/features/Questions/EntityQuestion/utils.ts +++ b/packages/datatrak-web/src/features/EntitySelector/useEntityBaseFilters.ts @@ -2,16 +2,17 @@ * Tupaia * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -import { useParams } from 'react-router-dom'; import { SurveyScreenComponentConfig } from '@tupaia/types'; -import { useSurveyForm } from '../../Survey'; - -export const useEntityBaseFilters = (config: SurveyScreenComponentConfig) => { - const { getAnswerByQuestionId } = useSurveyForm(); - const { countryCode } = useParams(); +export const useEntityBaseFilters = ( + config?: SurveyScreenComponentConfig | null, + answers?: Record, + countryCode?: string, +) => { const filters = { countryCode } as Record; + if (!config) return filters; + const filter = config?.entity?.filter; if (!filter) { return filters; @@ -23,15 +24,18 @@ export const useEntityBaseFilters = (config: SurveyScreenComponentConfig) => { filters.type = Array.isArray(type) ? type.join(',') : type; } - if (parentId && parentId.questionId) { - filters['parentId'] = getAnswerByQuestionId(parentId.questionId); + if (!answers) return filters; + + if (parentId && parentId.questionId && answers?.[parentId.questionId]) { + filters.parentId = answers[parentId.questionId]; } - if (grandparentId && grandparentId.questionId) { - filters['grandparentId'] = getAnswerByQuestionId(grandparentId.questionId); + if (grandparentId && grandparentId.questionId && answers?.[grandparentId.questionId]) { + filters.grandparentId = answers[grandparentId.questionId]; } if (attributes) { Object.entries(attributes).forEach(([key, attrConfig]) => { - const filterValue = getAnswerByQuestionId(attrConfig.questionId); + if (answers?.[attrConfig.questionId] === undefined) return; + const filterValue = answers?.[attrConfig.questionId]; filters[`attributes->>${key}`] = filterValue; }); } diff --git a/packages/datatrak-web/src/features/GroupedSurveyList.tsx b/packages/datatrak-web/src/features/GroupedSurveyList.tsx new file mode 100644 index 0000000000..b5465e0096 --- /dev/null +++ b/packages/datatrak-web/src/features/GroupedSurveyList.tsx @@ -0,0 +1,114 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React, { 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 { Survey } from '../types'; +import { useCurrentUserContext, useProjectSurveys } from '../api'; + +const ListWrapper = styled.div` + max-height: 35rem; + display: flex; + flex-direction: column; + overflow: auto; + flex: 1; + ${({ theme }) => theme.breakpoints.down('sm')} { + max-height: 100%; + } +`; + +const sortAlphanumerically = (a: ListItemType, b: ListItemType) => { + return (a.content as string).trim()?.localeCompare((b.content as string).trim(), 'en', { + numeric: true, + }); +}; + +interface GroupedSurveyListProps { + setSelectedSurvey: (surveyCode: Survey['code'] | null) => void; + selectedSurvey: Survey['code'] | null; + selectedCountry?: Country | null; + label?: string; + labelProps?: FormLabelProps & { + component?: React.ElementType; + }; + error?: string; +} + +export const GroupedSurveyList = ({ + setSelectedSurvey, + selectedSurvey, + selectedCountry, + label, + labelProps, + error, +}: GroupedSurveyListProps) => { + const user = useCurrentUserContext(); + const { data: surveys } = useProjectSurveys(user?.projectId, selectedCountry?.name); + const groupedSurveys = + surveys + ?.reduce((acc: ListItemType[], survey: Survey) => { + const { surveyGroupName, name, code } = survey; + const formattedSurvey = { + content: name, + value: code, + selected: selectedSurvey === code, + icon: , + }; + // if there is no surveyGroupName, add the survey to the list as a top level item + if (!surveyGroupName) { + return [...acc, formattedSurvey]; + } + const group = acc.find(({ content }) => content === surveyGroupName); + // if the surveyGroupName doesn't exist in the list, add it as a top level item + if (!group) { + return [ + ...acc, + { + content: surveyGroupName, + icon: , + value: surveyGroupName, + children: [formattedSurvey], + }, + ]; + } + // if the surveyGroupName exists in the list, add the survey to the children + return acc.map(item => { + if (item.content === surveyGroupName) { + return { + ...item, + // sort the folder items alphanumerically + children: [...(item.children || []), formattedSurvey].sort(sortAlphanumerically), + }; + } + return item; + }); + }, []) + ?.sort(sortAlphanumerically) ?? []; + + useEffect(() => { + // when the surveys change, check if the selected survey is still in the list. If not, clear the selection + if (selectedSurvey && !surveys?.find(survey => survey.code === selectedSurvey)) { + setSelectedSurvey(null); + } + }, [JSON.stringify(surveys)]); + + const onSelectSurvey = (listItem: ListItemType | null) => { + if (!listItem) return setSelectedSurvey(null); + setSelectedSurvey(listItem?.value as Survey['code']); + }; + return ( + + + {error && {error}} + + ); +}; diff --git a/packages/datatrak-web/src/features/Leaderboard/LeaderboardTable.tsx b/packages/datatrak-web/src/features/Leaderboard/LeaderboardTable.tsx index c5db50f901..5b846f5e1c 100644 --- a/packages/datatrak-web/src/features/Leaderboard/LeaderboardTable.tsx +++ b/packages/datatrak-web/src/features/Leaderboard/LeaderboardTable.tsx @@ -120,7 +120,7 @@ export const LeaderboardTable = ({ - {user?.userName} + {user?.fullName} {userRewards?.coconuts} diff --git a/packages/datatrak-web/src/features/Questions/EntityQuestion.tsx b/packages/datatrak-web/src/features/Questions/EntityQuestion.tsx new file mode 100644 index 0000000000..7d55606ca9 --- /dev/null +++ b/packages/datatrak-web/src/features/Questions/EntityQuestion.tsx @@ -0,0 +1,54 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import { useParams } from 'react-router'; +import { Typography } from '@material-ui/core'; +import { SurveyQuestionInputProps } from '../../types'; +import { useSurveyForm } from '..'; +import { EntitySelector } from '../EntitySelector'; + +export const EntityQuestion = ({ + id, + label, + detailLabel, + name, + required, + controllerProps: { onChange, value, ref, invalid }, + config, +}: SurveyQuestionInputProps) => { + const { countryCode } = useParams(); + const { isReviewScreen, isResponseScreen, formData } = useSurveyForm(); + + const { surveyProjectCode } = useSurveyForm(); + + return ( + + ); +}; diff --git a/packages/datatrak-web/src/features/Questions/EntityQuestion/EntityQuestion.tsx b/packages/datatrak-web/src/features/Questions/EntityQuestion/EntityQuestion.tsx deleted file mode 100644 index 17dad9d242..0000000000 --- a/packages/datatrak-web/src/features/Questions/EntityQuestion/EntityQuestion.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd - */ - -import React, { useState } from 'react'; -import styled from 'styled-components'; -import { useFormContext } from 'react-hook-form'; -import { FormHelperText, Typography } from '@material-ui/core'; -import { SpinningLoader, useDebounce } from '@tupaia/ui-components'; -import { SurveyQuestionInputProps } from '../../../types'; -import { useProjectEntities, useEntityById } from '../../../api'; -import { useSurveyForm } from '../..'; -import { ResultsList } from './ResultsList'; -import { SearchField } from './SearchField'; -import { useEntityBaseFilters } from './utils'; - -const Container = styled.div` - width: 100%; - z-index: 0; - - fieldset:disabled & { - pointer-events: none; - } -`; - -const Label = styled(Typography).attrs({ - variant: 'h4', -})` - font-size: 1rem; - cursor: pointer; -`; - -const useSearchResults = (searchValue, config) => { - const filter = useEntityBaseFilters(config); - const { surveyProjectCode } = useSurveyForm(); - - const debouncedSearch = useDebounce(searchValue!, 200); - return useProjectEntities(surveyProjectCode, { - fields: ['id', 'parent_name', 'code', 'name', 'type'], - filter, - searchString: debouncedSearch, - }); -}; - -export const EntityQuestion = ({ - id, - label, - detailLabel, - name, - required, - controllerProps: { onChange, value, ref, invalid }, - config, -}: SurveyQuestionInputProps) => { - const { isReviewScreen, isResponseScreen } = useSurveyForm(); - const { errors } = useFormContext(); - const [isDirty, setIsDirty] = useState(false); - const [searchValue, setSearchValue] = useState(''); - - // Display a previously selected value - useEntityById(value, { - staleTime: 0, // Needs to be 0 to make sure the entity is fetched on first render - enabled: !!value && !searchValue, - onSuccess: entityData => { - if (!isDirty) { - setSearchValue(entityData.name); - } - }, - }); - const onChangeSearch = newValue => { - setIsDirty(true); - setSearchValue(newValue); - }; - - const onSelect = entity => { - setIsDirty(true); - onChange(entity.value); - }; - - const { data: searchResults, isLoading, isFetched } = useSearchResults(searchValue, config); - - const displayResults = searchResults?.filter(({ name: entityName }) => { - if (isDirty || !value) { - return true; - } - return entityName === searchValue; - }); - - return ( - - {isReviewScreen || isResponseScreen ? ( - - ) : ( - - )} - {errors && errors[name!] && *{errors[name!].message}} - {!isFetched || isLoading ? ( - - ) : ( - - )} - - ); -}; diff --git a/packages/datatrak-web/src/features/Questions/EntityQuestion/index.ts b/packages/datatrak-web/src/features/Questions/EntityQuestion/index.ts deleted file mode 100644 index 68fced4698..0000000000 --- a/packages/datatrak-web/src/features/Questions/EntityQuestion/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd - */ - -export { EntityQuestion } from './EntityQuestion'; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx new file mode 100644 index 0000000000..702fdfe72e --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx @@ -0,0 +1,67 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React, { useState } from 'react'; +import throttle from 'lodash.throttle'; +import { useWatch } from 'react-hook-form'; +import { Country, DatatrakWebSurveyUsersRequest } from '@tupaia/types'; +import { Autocomplete } from '../../../components'; +import { useSurveyUsers } from '../../../api'; + +type User = DatatrakWebSurveyUsersRequest.ResBody[0]; + +interface AssigneeInputProps { + value: string | null; + onChange: (value: User['id'] | null) => void; + inputRef?: React.Ref; + selectedCountry?: Country | null; +} + +export const AssigneeInput = ({ + value, + onChange, + inputRef, + selectedCountry, +}: AssigneeInputProps) => { + const [searchValue, setSearchValue] = useState(''); + const { surveyCode } = useWatch('surveyCode'); + + const { data: users = [], isLoading } = useSurveyUsers( + surveyCode, + selectedCountry?.code, + searchValue, + ); + + const onChangeAssignee = (_e, newSelection: User | null) => { + onChange(newSelection?.id ?? null); + }; + + const options = + users?.map(user => ({ + ...user, + value: user.id, + label: user.name, + })) ?? []; + + const selection = options.find(option => option.id === value); + + return ( + { + setSearchValue(newValue); + }, 200)} + inputValue={searchValue} + getOptionLabel={option => option.label} + getOptionSelected={option => option.id === value} + placeholder="Search..." + loading={isLoading} + /> + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx new file mode 100644 index 0000000000..b68e348a41 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx @@ -0,0 +1,297 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React, { useEffect } from 'react'; +import styled from 'styled-components'; +import { useForm, Controller, FormProvider } from 'react-hook-form'; +import { LoadingContainer, Modal, TextField } from '@tupaia/ui-components'; +import { ButtonProps } from '@material-ui/core'; +import { useCreateTask, useUser } from '../../../api'; +import { CountrySelector, useUserCountries } from '../../CountrySelector'; +import { GroupedSurveyList } from '../../GroupedSurveyList'; +import { DueDatePicker } from '../DueDatePicker'; +import { RepeatScheduleInput } from './RepeatScheduleInput'; +import { EntityInput } from './EntityInput'; +import { AssigneeInput } from './AssigneeInput'; + +const CountrySelectorWrapper = styled.div` + display: flex; + justify-content: flex-end; + .MuiInputBase-input.MuiSelect-selectMenu { + font-size: 0.75rem; + } +`; + +const Form = styled.form` + .MuiFormLabel-root { + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; + margin-block-end: 0.2rem; + font-size: 0.875rem; + } + .MuiFormLabel-asterisk { + color: ${({ theme }) => theme.palette.error.main}; + } + .MuiInputBase-root { + font-size: 0.875rem; + } + .MuiOutlinedInput-input { + padding-block: 0.9rem; + } + input::placeholder { + color: ${({ theme }) => theme.palette.text.secondary}; + } + .MuiOutlinedInput-notchedOutline { + border-color: ${({ theme }) => theme.palette.divider}; + } + .MuiInputBase-root.Mui-error { + background-color: transparent; + } + .loading-screen { + border: none; + background-color: ${({ theme }) => theme.palette.background.paper}; + } +`; + +const ListSelectWrapper = styled.div` + margin-block-end: 1.8rem; + .list-wrapper { + height: 15rem; + max-height: 15rem; + padding: 1rem; + } + + .entity-selector-content { + padding-block: 1rem; + border: 1px solid ${({ theme }) => theme.palette.divider}; + border-radius: 3px; + .MuiFormControl-root { + width: auto; + margin-inline: 1rem; + padding-block-end: 1rem; + border-bottom: 1px solid ${({ theme }) => theme.palette.divider}; + } + .list-wrapper { + border-top: 0; + margin-block-start: 0; + padding-block-start: 0; + } + } +`; + +const InputRow = styled.div` + display: flex; + justify-content: space-between; + margin-block-end: 1.2rem; + > * { + width: 48%; + margin-block-end: 0; + } +`; + +const CommentsInput = styled(TextField).attrs({ + multiline: true, + variant: 'outlined', + fullWidth: true, + rows: 4, +})` + .MuiOutlinedInput-inputMultiline { + padding-inline: 1rem; + } +`; + +const Wrapper = styled.div` + .loading-screen { + border: none; + background-color: ${({ theme }) => theme.palette.background.paper}; + } +`; + +interface CreateTaskModalProps { + open: boolean; + onClose: () => void; +} + +export const CreateTaskModal = ({ open, onClose }: CreateTaskModalProps) => { + const defaultValues = { + surveyCode: null, + entityId: null, + dueDate: new Date(), + repeatSchedule: null, + assigneeId: null, + }; + const formContext = useForm({ + mode: 'onChange', + defaultValues, + }); + const { + handleSubmit, + control, + setValue, + reset, + formState: { isValid, dirtyFields }, + } = formContext; + + const { + countries, + selectedCountry, + updateSelectedCountry, + isLoading: isLoadingCountries, + } = useUserCountries(); + const { isLoading: isLoadingUser, isFetching: isFetchingUser } = useUser(); + + const isLoadingData = isLoadingCountries || isLoadingUser || isFetchingUser; + const { mutate: createTask, isLoading: isSaving } = useCreateTask(onClose); + + const buttons: { + text: string; + onClick: () => void; + variant?: ButtonProps['variant']; // typing here because simply giving 'outlined' as default value is causing a type mismatch error + id: string; + disabled?: boolean; + }[] = [ + { + text: 'Cancel', + onClick: onClose, + variant: 'outlined', + id: 'cancel', + disabled: isSaving, + }, + { + text: 'Save', + onClick: handleSubmit(createTask), + id: 'save', + disabled: !isValid || isSaving || isLoadingData, + }, + ]; + + useEffect(() => { + if (!selectedCountry?.code) return; + const { surveyCode, entityId } = dirtyFields; + // reset surveyCode and entityId when country changes, if they are dirty + if (surveyCode) { + setValue('surveyCode', null, { shouldValidate: true }); + } + if (entityId) { + setValue('entityId', null, { shouldValidate: true }); + } + }, [selectedCountry?.code]); + + useEffect(() => { + if (open) { + reset(defaultValues); + } + }, [open]); + + return ( + + + + +
+ + + + ( + + + + )} + /> + { + return ( + + + + ); + }} + /> + + { + return ( + + ); + }} + /> + ( + + )} + /> + + + + ( + + )} + /> + + + {/** This is a placeholder for when we add in comments functionality */} + + +
+
+
+
+ ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx new file mode 100644 index 0000000000..7119ceb1ca --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx @@ -0,0 +1,78 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import { useWatch } from 'react-hook-form'; +import { Country, EntityType, QuestionType } from '@tupaia/types'; +import { EntitySelector } from '../../EntitySelector'; +import { useCurrentUserContext, useSurvey } from '../../../api'; +import { getAllSurveyComponents } from '../../Survey'; + +interface EntityInputProps { + onChange: (value: string) => void; + value: string; + selectedCountry?: Country | null; + inputRef?: React.Ref; + name: string; + invalid?: boolean; +} + +export const EntityInput = ({ + onChange, + value, + selectedCountry, + inputRef, + name, + invalid, +}: EntityInputProps) => { + const { surveyCode } = useWatch('surveyCode'); + const user = useCurrentUserContext(); + const { data: survey, isLoading: isLoadingSurvey } = useSurvey(surveyCode); + const getPrimaryEntityQuestionConfig = () => { + if (!survey) return null; + const flattenedQuestions = getAllSurveyComponents(survey.screens ?? []); + const primaryEntityQuestion = flattenedQuestions.find( + question => question.type === QuestionType.PrimaryEntity, + ); + if (primaryEntityQuestion?.config?.entity?.filter) return primaryEntityQuestion.config; + // default to country filter if no primary entity question is found or it doesn't have an entity filter + return { + entity: { + filter: { + type: EntityType.country, + }, + }, + }; + }; + + const primaryEntityQuestionConfig = getPrimaryEntityQuestionConfig(); + + return ( + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx new file mode 100644 index 0000000000..b23ff42523 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx @@ -0,0 +1,106 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React, { useEffect } from 'react'; +import { Select as BaseSelect, MenuItem, FormControl, FormLabel } from '@material-ui/core'; +import { format, lastDayOfMonth } from 'date-fns'; +import { useWatch } from 'react-hook-form'; +import styled from 'styled-components'; + +const Select = styled(BaseSelect)` + &.Mui-disabled { + background-color: ${({ theme }) => theme.palette.background.default}; + } +`; + +const useRepeatScheduleOptions = dueDate => { + const noRepeat = { + label: "Doesn't repeat", + value: '', + }; + + if (!dueDate) { + return [noRepeat]; + } + + const dueDateObject = new Date(dueDate); + + const dayOfWeek = format(dueDateObject, 'EEEE'); + const dateOfMonth = format(dueDateObject, 'do'); + + const month = format(dueDateObject, 'MMM'); + + const lastDateOfMonth = format(lastDayOfMonth(dueDateObject), 'do'); + + const isLastDayOfMonth = dateOfMonth === lastDateOfMonth; + + // If the due date is the last day of the month, we don't need to show the date, just always repeat on the last day. Otherwise, show the date. + // In the case of February, if the selected date is, for example, the 29th/30th/31st of June, we would repeat on the last day of the month. + const monthlyOption = isLastDayOfMonth + ? 'Monthly on the last day' + : `Monthly on the ${dateOfMonth}`; + + // TODO: When saving, add some logic here when we handle recurring tasks + return [ + noRepeat, + { + label: 'Daily', + value: 'daily', + }, + { + label: `Weekly on ${dayOfWeek}`, + value: 'weekly', + }, + { + label: monthlyOption, + value: 'monthly', + }, + { + label: `Yearly on ${dateOfMonth} of ${month}`, + value: 'yearly', + }, + ]; +}; + +interface RepeatScheduleInputProps { + value: string; + onChange: ( + value: React.ChangeEvent<{ + name?: string | undefined; + value: unknown; + }> | null, + ) => void; +} + +export const RepeatScheduleInput = ({ value = '', onChange }: RepeatScheduleInputProps) => { + const { dueDate } = useWatch('dueDate'); + const repeatScheduleOptions = useRepeatScheduleOptions(dueDate); + + useEffect(() => { + if (!dueDate) { + onChange(null); + } + }, [dueDate]); + + return ( + + Repeating task + + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/index.ts b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/index.ts new file mode 100644 index 0000000000..1c6b767975 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/index.ts @@ -0,0 +1,6 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { CreateTaskModal } from './CreateTaskModal'; diff --git a/packages/datatrak-web/src/features/Tasks/DueDatePicker.tsx b/packages/datatrak-web/src/features/Tasks/DueDatePicker.tsx new file mode 100644 index 0000000000..218bf7f8e0 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/DueDatePicker.tsx @@ -0,0 +1,129 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React, { useEffect, useState } from 'react'; +import { isValid } from 'date-fns'; +import styled from 'styled-components'; +import { DatePicker } from '@tupaia/ui-components'; +import { stripTimezoneFromDate } from '@tupaia/utils'; + +const Wrapper = styled.div` + .MuiFormControl-root { + margin-block-end: 0; + } + .MuiButtonBase-root.MuiIconButton-root { + color: ${props => props.theme.palette.primary.main}; + } + .MuiInputBase-input { + padding-inline-end: 0; + padding-inline-start: 1rem; + font-size: inherit; + line-height: normal; + color: inherit; + } + .MuiInputAdornment-positionEnd { + margin-inline-start: 0; + } + .MuiOutlinedInput-adornedEnd { + padding-inline-end: 0; + padding-inline-start: 0; + } + .MuiFormLabel-root { + margin-block-end: 0.25rem; + line-height: 1.2; + } + .MuiSvgIcon-root { + font-size: 1rem; + } +`; + +interface DueDatePickerProps { + value?: string | null; + onChange: (value: string | null) => void; + disablePast?: boolean; + fullWidth?: boolean; + required?: boolean; + label?: string; + inputRef?: React.Ref; + invalid?: boolean; + helperText?: string; +} + +export const DueDatePicker = ({ + value, + onChange, + label, + disablePast, + fullWidth, + required, + inputRef, + invalid, + helperText, +}: DueDatePickerProps) => { + const [date, setDate] = useState(value ?? null); + + // update in local state to be the end of the selected date + // this is also to handle invalid dates, so the filter doesn't get updated until a valid date is selected/entered + const updateSelectedDate = (newValue: string | null) => { + if (!newValue) return setDate(''); + if (!isValid(new Date(newValue))) return setDate(''); + const endOfDay = new Date(new Date(newValue).setHours(23, 59, 59, 999)); + const newDate = stripTimezoneFromDate(endOfDay); + setDate(newDate); + }; + + // if the date is updated, update the value + useEffect(() => { + if (date === value) return; + onChange(date); + }, [date]); + + // if the value is updated, update the local state. This is to handle, for example, dates that are updated from the URL params + useEffect(() => { + if (value === date) return; + + setDate(value ?? ''); + }, [value]); + + const getLocaleDateFormat = () => { + const localeCode = window.navigator.language; + const parts = new Intl.DateTimeFormat(localeCode).formatToParts(); + return parts + .map(({ type, value: partValue }) => { + switch (type) { + case 'year': + return 'yyyy'; + case 'month': + return 'mm'; + case 'day': + return 'dd'; + default: + return partValue; + } + }) + .join(''); + }; + + const placeholder = getLocaleDateFormat(); + + return ( + + + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/DueDateFilter.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/DueDateFilter.tsx deleted file mode 100644 index 88aa88eceb..0000000000 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/DueDateFilter.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd - */ -import React, { useEffect, useState } from 'react'; -import { format, isValid } from 'date-fns'; -import styled from 'styled-components'; -import { DatePicker } from '@tupaia/ui-components'; - -const Wrapper = styled.div` - .MuiButtonBase-root.MuiIconButton-root { - color: ${props => props.theme.palette.primary.main}; - } - .MuiInputBase-input { - padding-inline-end: 0; - } - .MuiInputAdornment-positionEnd { - margin-inline-start: 0; - } - .MuiOutlinedInput-adornedEnd { - padding-inline-end: 0; - } -`; - -const DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss'; - -interface DueDateFilterProps { - filter: { value: string } | undefined; - onChange: (value: string | null) => void; -} - -export const DueDateFilter = ({ filter, onChange }: DueDateFilterProps) => { - const [date, setDate] = useState(filter?.value ?? null); - - // update in local state to be the end of the selected date - // this is also to handle invalid dates, so the filter doesn't get updated until a valid date is selected/entered - const updateSelectedDate = (value: string | null) => { - if (!value) return setDate(null); - if (!isValid(new Date(value))) return; - const endOfDay = new Date(value).setHours(23, 59, 59, 999); - const newDate = format(endOfDay, DATE_FORMAT); - setDate(newDate); - }; - - // if the date is updated, update the filter - useEffect(() => { - if (date === filter?.value) return; - onChange(date); - }, [date]); - - // if the filter is updated, update the local state. This is to handle, for example, dates that are updated from the URL params - useEffect(() => { - if (filter?.value === date) return; - - setDate(filter?.value ?? null); - }, [filter?.value]); - - const getLocaleDateFormat = () => { - const localeCode = window.navigator.language; - const parts = new Intl.DateTimeFormat(localeCode).formatToParts(); - return parts - .map(({ type, value }) => { - switch (type) { - case 'year': - return 'yyyy'; - case 'month': - return 'mm'; - case 'day': - return 'dd'; - default: - return value; - } - }) - .join(''); - }; - - const placeholder = getLocaleDateFormat(); - - return ( - - - - ); -}; diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx index 560febc845..0612e239f1 100644 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx @@ -13,9 +13,9 @@ import { Button } from '../../../components'; import { useCurrentUserContext, useTasks } from '../../../api'; import { displayDate } from '../../../utils'; import { ROUTES } from '../../../constants'; +import { DueDatePicker } from '../DueDatePicker'; import { StatusPill } from '../StatusPill'; import { StatusFilter } from './StatusFilter'; -import { DueDateFilter } from './DueDateFilter'; type Task = DatatrakWebTasksRequest.ResBody['tasks'][0]; @@ -110,7 +110,7 @@ const COLUMNS = [ accessor: row => displayDate(row.dueDate), id: 'due_date', filterable: true, - Filter: DueDateFilter, + Filter: DueDatePicker, disableResizing: true, }, { diff --git a/packages/datatrak-web/src/features/Tasks/index.ts b/packages/datatrak-web/src/features/Tasks/index.ts index 9ff3bda7a1..3cdbd1d252 100644 --- a/packages/datatrak-web/src/features/Tasks/index.ts +++ b/packages/datatrak-web/src/features/Tasks/index.ts @@ -5,3 +5,4 @@ export { TaskPageHeader } from './TaskPageHeader'; export { TasksTable } from './TasksTable'; +export { CreateTaskModal } from './CreateTaskModal'; diff --git a/packages/datatrak-web/src/features/index.ts b/packages/datatrak-web/src/features/index.ts index 253c6b748a..2a5b6c4a50 100644 --- a/packages/datatrak-web/src/features/index.ts +++ b/packages/datatrak-web/src/features/index.ts @@ -20,4 +20,6 @@ export { RequestProjectAccess } from './RequestProjectAccess'; export { MobileAppPrompt } from './MobileAppPrompt'; export { Leaderboard } from './Leaderboard'; export { Reports } from './Reports'; -export { TaskPageHeader, TasksTable } from './Tasks'; +export { TaskPageHeader, TasksTable, CreateTaskModal } from './Tasks'; +export { useUserCountries, CountrySelector } from './CountrySelector'; +export { GroupedSurveyList } from './GroupedSurveyList'; diff --git a/packages/datatrak-web/src/layout/UserMenu/DrawerMenu.tsx b/packages/datatrak-web/src/layout/UserMenu/DrawerMenu.tsx index 85bfaa4525..d091b172fa 100644 --- a/packages/datatrak-web/src/layout/UserMenu/DrawerMenu.tsx +++ b/packages/datatrak-web/src/layout/UserMenu/DrawerMenu.tsx @@ -105,7 +105,7 @@ export const DrawerMenu = ({ menuOpen, onCloseMenu, openProjectModal }: DrawerMe > - {user.userName && {user.userName}} + {user.fullName && {user.fullName}} {user.project?.name && ( {user.project.name} diff --git a/packages/datatrak-web/src/layout/UserMenu/UserInfo.tsx b/packages/datatrak-web/src/layout/UserMenu/UserInfo.tsx index a8f89e2a6a..9fe9ed4a2f 100644 --- a/packages/datatrak-web/src/layout/UserMenu/UserInfo.tsx +++ b/packages/datatrak-web/src/layout/UserMenu/UserInfo.tsx @@ -60,13 +60,13 @@ const AuthButtons = styled.div` * This is the displayed user name OR the login/register buttons on desktop */ export const UserInfo = () => { - const { isLoggedIn, projectId, userName } = useCurrentUserContext(); + const { isLoggedIn, projectId, fullName } = useCurrentUserContext(); return ( {isLoggedIn ? ( - {userName} + {fullName} {projectId && } ) : ( diff --git a/packages/datatrak-web/src/views/AccountSettingsPage/DeleteAccountSection/UserDetails.tsx b/packages/datatrak-web/src/views/AccountSettingsPage/DeleteAccountSection/UserDetails.tsx index 339b6cc974..b36a670cf7 100644 --- a/packages/datatrak-web/src/views/AccountSettingsPage/DeleteAccountSection/UserDetails.tsx +++ b/packages/datatrak-web/src/views/AccountSettingsPage/DeleteAccountSection/UserDetails.tsx @@ -64,7 +64,7 @@ export const UserDetails = () => { return (
- {user.userName} + {user.fullName} {user.email}
diff --git a/packages/datatrak-web/src/views/SurveySelectPage/SurveySelectPage.tsx b/packages/datatrak-web/src/views/SurveySelectPage.tsx similarity index 51% rename from packages/datatrak-web/src/views/SurveySelectPage/SurveySelectPage.tsx rename to packages/datatrak-web/src/views/SurveySelectPage.tsx index 3faf88c5b6..f9efd35c3f 100644 --- a/packages/datatrak-web/src/views/SurveySelectPage/SurveySelectPage.tsx +++ b/packages/datatrak-web/src/views/SurveySelectPage.tsx @@ -2,18 +2,17 @@ * Tupaia * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { useNavigate } from 'react-router'; import styled from 'styled-components'; import { DialogActions, Paper, Typography } from '@material-ui/core'; import { SpinningLoader } from '@tupaia/ui-components'; -import { useEditUser } from '../../api/mutations'; -import { SelectList, ListItemType, Button, SurveyFolderIcon, SurveyIcon } from '../../components'; -import { Survey } from '../../types'; -import { useCurrentUserContext, useProjectSurveys } from '../../api'; -import { HEADER_HEIGHT } from '../../constants'; -import { SurveyCountrySelector } from './SurveyCountrySelector'; -import { useUserCountries } from './useUserCountries'; +import { useEditUser } from '../api/mutations'; +import { Button } from '../components'; +import { useCurrentUserContext, useProjectSurveys } from '../api'; +import { HEADER_HEIGHT } from '../constants'; +import { CountrySelector, GroupedSurveyList, useUserCountries } from '../features'; +import { Survey } from '../types'; const Container = styled(Paper).attrs({ variant: 'outlined', @@ -52,17 +51,6 @@ const LoadingContainer = styled.div` flex: 1; `; -const ListWrapper = styled.div` - max-height: 35rem; - display: flex; - flex-direction: column; - overflow: auto; - flex: 1; - ${({ theme }) => theme.breakpoints.down('sm')} { - max-height: 100%; - } -`; - const HeaderWrapper = styled.div` display: flex; align-items: center; @@ -93,15 +81,9 @@ const Subheader = styled(Typography).attrs({ } `; -const sortAlphanumerically = (a: ListItemType, b: ListItemType) => { - return (a.content as string).trim()?.localeCompare((b.content as string).trim(), 'en', { - numeric: true, - }); -}; - export const SurveySelectPage = () => { const navigate = useNavigate(); - const [selectedSurvey, setSelectedSurvey] = useState(null); + const [selectedSurvey, setSelectedSurvey] = useState(null); const { countries, selectedCountry, @@ -110,54 +92,12 @@ export const SurveySelectPage = () => { isLoading: isLoadingCountries, } = useUserCountries(); const navigateToSurvey = () => { - navigate(`/survey/${selectedCountry?.code}/${selectedSurvey?.value}`); + navigate(`/survey/${selectedCountry?.code}/${selectedSurvey}`); }; const { mutate: updateUser, isLoading: isUpdatingUser } = useEditUser(navigateToSurvey); const user = useCurrentUserContext(); - const { data: surveys, isLoading } = useProjectSurveys(user.projectId, selectedCountry?.name); - - // group the data by surveyGroupName for the list, and add the value and selected properties - const groupedSurveys = - surveys - ?.reduce((acc: ListItemType[], survey: Survey) => { - const { surveyGroupName, name, code } = survey; - const formattedSurvey = { - content: name, - value: code, - selected: selectedSurvey?.value === code, - icon: , - }; - // if there is no surveyGroupName, add the survey to the list as a top level item - if (!surveyGroupName) { - return [...acc, formattedSurvey]; - } - const group = acc.find(({ content }) => content === surveyGroupName); - // if the surveyGroupName doesn't exist in the list, add it as a top level item - if (!group) { - return [ - ...acc, - { - content: surveyGroupName, - icon: , - value: surveyGroupName, - children: [formattedSurvey], - }, - ]; - } - // if the surveyGroupName exists in the list, add the survey to the children - return acc.map(item => { - if (item.content === surveyGroupName) { - return { - ...item, - // sort the folder items alphanumerically - children: [...(item.children || []), formattedSurvey].sort(sortAlphanumerically), - }; - } - return item; - }); - }, []) - ?.sort(sortAlphanumerically) ?? []; + const { isLoading } = useProjectSurveys(user.projectId, selectedCountry?.name); const handleSelectSurvey = () => { if (countryHasUpdated) { @@ -166,13 +106,6 @@ export const SurveySelectPage = () => { } else navigateToSurvey(); }; - useEffect(() => { - // when the surveys change, check if the selected survey is still in the list. If not, clear the selection - if (selectedSurvey && !surveys?.find(survey => survey.code === selectedSurvey.value)) { - setSelectedSurvey(null); - } - }, [JSON.stringify(surveys)]); - const showLoader = isLoading || isLoadingCountries || isUpdatingUser; return ( @@ -181,7 +114,7 @@ export const SurveySelectPage = () => { Select survey Select a survey from the list below - { ) : ( - - - + )}