diff --git a/packages/admin-panel/src/VizBuilderApp/components/PreviewOptions/LocationField.jsx b/packages/admin-panel/src/VizBuilderApp/components/PreviewOptions/LocationField.jsx index 948523eb37..87246547b3 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/PreviewOptions/LocationField.jsx +++ b/packages/admin-panel/src/VizBuilderApp/components/PreviewOptions/LocationField.jsx @@ -7,6 +7,7 @@ import React, { useState } from 'react'; import { Autocomplete } from './Autocomplete'; import { useVizConfigContext } from '../../context'; import { useEntityByCode, useLocations } from '../../api'; +import { EntityOptionLabel } from '../../../widgets'; export const LocationField = () => { const [locationSearch, setLocationSearch] = useState(''); @@ -39,7 +40,9 @@ export const LocationField = () => { setLocationSearch(newValue); }} getOptionLabel={option => option.name} - renderOption={option => {option.name}} + renderOption={option => { + return ; + }} onChange={(_, newLocation) => { setLocation(newLocation); }} diff --git a/packages/admin-panel/src/autocomplete/Autocomplete.jsx b/packages/admin-panel/src/autocomplete/Autocomplete.jsx index e8398438de..a5bad958aa 100644 --- a/packages/admin-panel/src/autocomplete/Autocomplete.jsx +++ b/packages/admin-panel/src/autocomplete/Autocomplete.jsx @@ -38,6 +38,7 @@ export const Autocomplete = props => { canCreateNewOptions, allowMultipleValues, optionLabelKey, + renderOption, muiProps, error, tooltip, @@ -110,6 +111,7 @@ export const Autocomplete = props => { options={options} getOptionSelected={getOptionSelected} getOptionLabel={getOptionLabel} + renderOption={renderOption} loading={isLoading} onChange={onChangeSelection} onInputChange={(event, newValue) => { @@ -134,6 +136,7 @@ Autocomplete.propTypes = { options: PropTypes.array.isRequired, getOptionSelected: PropTypes.func.isRequired, getOptionLabel: PropTypes.func.isRequired, + renderOption: PropTypes.func, isLoading: PropTypes.bool, onChangeSelection: PropTypes.func.isRequired, onChangeSearchTerm: PropTypes.func, diff --git a/packages/admin-panel/src/autocomplete/ReduxAutocomplete.jsx b/packages/admin-panel/src/autocomplete/ReduxAutocomplete.jsx index 48ccb0305b..f254dd8594 100644 --- a/packages/admin-panel/src/autocomplete/ReduxAutocomplete.jsx +++ b/packages/admin-panel/src/autocomplete/ReduxAutocomplete.jsx @@ -9,6 +9,7 @@ import { connect } from 'react-redux'; import { getAutocompleteState } from './selectors'; import { changeSelection, changeSearchTerm, clearState } from './actions'; import { Autocomplete } from './Autocomplete'; +import { EntityOptionLabel } from '../widgets'; const getPlaceholder = (placeholder, selection) => { if (selection && selection.length) { @@ -41,6 +42,8 @@ const ReduxAutocompleteComponent = ({ error, tooltip, optionValueKey, + renderOption, + optionFields, }) => { const [hasUpdated, setHasUpdated] = React.useState(false); React.useEffect(() => { @@ -69,6 +72,12 @@ const ReduxAutocompleteComponent = ({ selectedValue = []; } + const getOptionRendered = option => { + if (renderOption) return renderOption(option); + if (!option || !option[optionLabelKey]) return ''; + return option[optionLabelKey]; + }; + return ( ); }; @@ -111,6 +121,7 @@ ReduxAutocompleteComponent.propTypes = { required: PropTypes.bool, error: PropTypes.bool, optionValueKey: PropTypes.string.isRequired, + renderOption: PropTypes.func, }; ReduxAutocompleteComponent.defaultProps = { @@ -149,6 +160,7 @@ const mapDispatchToProps = ( baseFilter, pageSize, distinct, + optionFields, }, ) => ({ programaticallyChangeSelection: initialValue => { @@ -199,6 +211,7 @@ const mapDispatchToProps = ( baseFilter, pageSize, distinct, + optionFields, ), ), onClearState: () => dispatch(clearState(reduxId)), diff --git a/packages/admin-panel/src/autocomplete/actions.js b/packages/admin-panel/src/autocomplete/actions.js index f0f157d996..0ce39e4439 100644 --- a/packages/admin-panel/src/autocomplete/actions.js +++ b/packages/admin-panel/src/autocomplete/actions.js @@ -31,6 +31,7 @@ export const changeSearchTerm = baseFilter = {}, pageSize = MAX_AUTOCOMPLETE_RESULTS, distinct = null, + columns = null, ) => async (dispatch, getState, { api }) => { const fetchId = generateId(); @@ -46,7 +47,7 @@ export const changeSearchTerm = filter: JSON.stringify(filter), pageSize, sort: JSON.stringify([`${labelColumn} ASC`]), - columns: JSON.stringify([labelColumn, valueColumn]), + columns: JSON.stringify(columns ? columns : [labelColumn, valueColumn]), distinct, }); dispatch({ diff --git a/packages/admin-panel/src/importExport/SurveyResponsesExportModal.jsx b/packages/admin-panel/src/importExport/SurveyResponsesExportModal.jsx index 6454df7372..ed044696b6 100644 --- a/packages/admin-panel/src/importExport/SurveyResponsesExportModal.jsx +++ b/packages/admin-panel/src/importExport/SurveyResponsesExportModal.jsx @@ -9,6 +9,7 @@ import { DateTimePicker, RadioGroup } from '@tupaia/ui-components'; import { stripTimezoneFromDate } from '@tupaia/utils'; import { ReduxAutocomplete } from '../autocomplete'; import { ExportModal } from './ExportModal'; +import { EntityOptionLabel } from '../widgets'; const MODES = { COUNTRY: { value: 'country', formInput: 'countryCode' }, @@ -92,6 +93,8 @@ export const SurveyResponsesExportModal = () => { endpoint="entities" optionLabelKey="name" optionValueKey="id" + renderOption={option => } + optionFields={['id', 'code', 'name']} allowMultipleValues /> )} diff --git a/packages/admin-panel/src/routes/visualisations/dashboardMailingLists.js b/packages/admin-panel/src/routes/visualisations/dashboardMailingLists.jsx similarity index 95% rename from packages/admin-panel/src/routes/visualisations/dashboardMailingLists.js rename to packages/admin-panel/src/routes/visualisations/dashboardMailingLists.jsx index 1f76348a1a..6e87f4a124 100644 --- a/packages/admin-panel/src/routes/visualisations/dashboardMailingLists.js +++ b/packages/admin-panel/src/routes/visualisations/dashboardMailingLists.jsx @@ -3,8 +3,10 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ +import React from 'react'; import { ArrayFilter } from '../../table/columnTypes/columnFilters'; import { prettyArray } from '../../utilities'; +import { EntityOptionLabel } from '../../widgets'; const RESOURCE_NAME = { singular: 'dashboard mailing list' }; @@ -52,6 +54,8 @@ const DASHBOARD_MAILING_LIST_FIELDS = { optionLabelKey: 'name', optionValueKey: 'id', labelTooltip: 'Select the entity this dashboard mailing list should be for', + renderOption: option => , + optionFields: ['id', 'code', 'name'], }, }, admin_permission_groups: { diff --git a/packages/admin-panel/src/routes/visualisations/visualisationsTabRoutes.jsx b/packages/admin-panel/src/routes/visualisations/visualisationsTabRoutes.jsx index fb45400577..eaec92dbab 100644 --- a/packages/admin-panel/src/routes/visualisations/visualisationsTabRoutes.jsx +++ b/packages/admin-panel/src/routes/visualisations/visualisationsTabRoutes.jsx @@ -26,12 +26,12 @@ export const visualisationsTabRoutes = { dashboards, dashboardRelations, dashboardMailingLists, + dataTables, legacyReports, mapOverlays, mapOverlayGroups, mapOverlayGroupRelations, indicators, - dataTables, socialFeed, ], }; diff --git a/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx b/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx index 612e97ddd6..fa845cc082 100644 --- a/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx +++ b/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx @@ -122,7 +122,7 @@ export const FileQuestionField = ({ value: uniqueFileName, onChange, label, maxS const api = useApiContext(); const downloadFile = async () => { - await api.download(`downloadFiles`, { uniqueFileNames: uniqueFileName }, fileName); + await api.download('downloadFiles', { uniqueFileNames: uniqueFileName }, fileName); }; return ( diff --git a/packages/admin-panel/src/surveyResponse/ResponseFields.jsx b/packages/admin-panel/src/surveyResponse/ResponseFields.jsx index e1170a08db..544b50125b 100644 --- a/packages/admin-panel/src/surveyResponse/ResponseFields.jsx +++ b/packages/admin-panel/src/surveyResponse/ResponseFields.jsx @@ -16,6 +16,7 @@ import { format } from 'date-fns'; import { Autocomplete } from '../autocomplete'; import { useDebounce } from '../utilities'; import { useEntities } from '../VizBuilderApp/api'; +import { EntityOptionLabel } from '../widgets'; const SectionWrapper = styled.div` display: grid; @@ -81,6 +82,9 @@ export const ResponseFields = ({ return option.id === selected.id; }} getOptionLabel={option => option?.name || ''} + renderOption={option => { + return ; + }} isLoading={entityIsLoading} onChangeSelection={(event, selectedValue) => { if (!selectedValue) { diff --git a/packages/admin-panel/src/theme/colors.js b/packages/admin-panel/src/theme/colors.js index 094c93cb90..6129e052e9 100644 --- a/packages/admin-panel/src/theme/colors.js +++ b/packages/admin-panel/src/theme/colors.js @@ -27,10 +27,11 @@ export const LIGHT_ORANGE = '#FFECE1'; // Greys (based on first 2 letters of hex code) export const GREY_72 = '#727D84'; export const GREY_9F = '#9FA6AA'; +export const GREY_B8 = '#B8B8B8'; +export const GREY_DE = '#DEDEDE'; export const GREY_E2 = '#E2E2E2'; export const GREY_F1 = '#F1F1F1'; export const GREY_FB = '#FBF9F9'; -export const GREY_DE = '#DEDEDE'; // Blues export const BLUE_BF = '#BFD5E4'; diff --git a/packages/admin-panel/src/widgets/EntityOptionLabel.jsx b/packages/admin-panel/src/widgets/EntityOptionLabel.jsx new file mode 100644 index 0000000000..a3840a6112 --- /dev/null +++ b/packages/admin-panel/src/widgets/EntityOptionLabel.jsx @@ -0,0 +1,31 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import styled from 'styled-components'; + +const StyledEntityOptionLabel = styled.div` + display: flex; + flex-direction: column; +`; + +const Name = styled.span` + font-style: ${props => props.theme.typography.fontWeightBold}; + color: ${props => props.theme.palette.text.primary}; +`; + +const Code = styled.span` + margin-top: 0.25rem; + color: ${props => props.theme.palette.text.secondary}; +`; + +export const EntityOptionLabel = ({ name, code }) => { + return ( + + {name} + {code} + + ); +}; diff --git a/packages/admin-panel/src/widgets/InputField/JsonEditor.jsx b/packages/admin-panel/src/widgets/InputField/JsonEditor.jsx index 94596e055d..956d1966d6 100644 --- a/packages/admin-panel/src/widgets/InputField/JsonEditor.jsx +++ b/packages/admin-panel/src/widgets/InputField/JsonEditor.jsx @@ -38,10 +38,6 @@ export const JsonEditor = ({ required, tooltip, }) => { - if (!value) { - return null; - } - let editorValue = value; if (typeof value === 'string') { @@ -64,7 +60,7 @@ export const JsonEditor = ({ mainMenuBar={false} statusBar={false} mode="code" - onChange={json => onChange(inputKey, stringify ? JSON.stringify(json) : json)} + onChange={json => onChange(inputKey, stringify ? JSON.stringify(json ?? {}) : json)} value={editorValue} htmlElementProps={{ className: 'jsoneditor-parent', diff --git a/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx b/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx index 50b769e26e..a35b037847 100644 --- a/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx +++ b/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx @@ -76,6 +76,8 @@ export const registerInputFields = () => { endpoint={props.optionsEndpoint} optionLabelKey={props.optionLabelKey} optionValueKey={props.optionValueKey} + optionFields={props.optionFields} + renderOption={props.renderOption} reduxId={props.inputKey} onChange={inputValue => props.onChange(props.inputKey, inputValue)} canCreateNewOptions={props.canCreateNewOptions} diff --git a/packages/admin-panel/src/widgets/index.js b/packages/admin-panel/src/widgets/index.js index 9a011f25cc..b9f573ca30 100644 --- a/packages/admin-panel/src/widgets/index.js +++ b/packages/admin-panel/src/widgets/index.js @@ -18,3 +18,4 @@ export { export { JsonEditor, JsonTreeEditor } from './JsonEditor'; export { SecondaryNavbar } from '../layout/navigation/SecondaryNavbar'; export { ConfirmDeleteModal } from './ConfirmDeleteModal'; +export { EntityOptionLabel } from './EntityOptionLabel'; diff --git a/packages/central-server/src/apiV2/GETHandler/helpers.js b/packages/central-server/src/apiV2/GETHandler/helpers.js index 09e9000e50..cd8b38b9ac 100644 --- a/packages/central-server/src/apiV2/GETHandler/helpers.js +++ b/packages/central-server/src/apiV2/GETHandler/helpers.js @@ -97,7 +97,7 @@ const constructJoinCondition = (recordType, baseRecordType, customJoinConditions joinType, }; if (join?.through) { - if ('nearTableKey' in join !== true || 'farTableKey' in join !== true) { + if (!('nearTableKey' in join) || !('farTableKey' in join)) { throw new ValidationError(`Incorrect format for customJoinConditions: ${recordType}`); } const nearTable = join.nearTableKey.split('.'); diff --git a/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts b/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts index f9b61c3acb..0ef34a55b8 100644 --- a/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts +++ b/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts @@ -450,13 +450,13 @@ describe('processSurveyResponse', () => { }); }); - it('should not add to recent_entities when type is entity question is not filled in', async () => { + it('should not add to recent_entities when type is entity question and is not filled in', async () => { const result = await processSurveyResponse(mockModels, { ...responseData, questions: [ { questionId: 'question1', - type: QuestionType.PrimaryEntity, + type: QuestionType.Entity, componentNumber: 1, text: 'question1', screenId: 'screen1', @@ -469,6 +469,27 @@ describe('processSurveyResponse', () => { expect(result.recent_entities).toEqual([]); }); + it('throw an error when type is primary entity question and is not filled in', async () => { + try { + const result = await processSurveyResponse(mockModels, { + ...responseData, + questions: [ + { + questionId: 'question1', + type: QuestionType.PrimaryEntity, + componentNumber: 1, + text: 'question1', + screenId: 'screen1', + config: {}, + }, + ], + answers: {}, + }); + } catch (error: any) { + expect(error.message).toBe('Primary Entity Question is a required field'); + } + }); + it('should use the country id for new entities if parent id is not filled in', async () => { const result = await processSurveyResponse(mockModels, { ...responseData, diff --git a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts index 91862f0822..27ad68033b 100644 --- a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts +++ b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts @@ -88,6 +88,9 @@ export const processSurveyResponse = async ( surveyResponse.qr_codes_to_create?.push(entityObj); } } + if (type === QuestionType.PrimaryEntity && !answer) { + throw new Error(`Primary Entity Question is a required field`); + } if (answer) { if (typeof answer !== 'string') { throw new Error( diff --git a/packages/datatrak-web/src/components/Autocomplete.tsx b/packages/datatrak-web/src/components/Autocomplete.tsx index c9c2e4f26e..db45ece660 100644 --- a/packages/datatrak-web/src/components/Autocomplete.tsx +++ b/packages/datatrak-web/src/components/Autocomplete.tsx @@ -39,10 +39,8 @@ const SelectedOption = styled(OptionWrapper)` display: flex; align-items: center; justify-content: space-between; - padding-left: 0.425rem; - padding-right: 0.425rem; - margin-left: 0.45rem; - margin-right: 0.45rem; + padding-inline: 0.425rem; + margin-inline: 0.45rem; border-radius: 3px; border: 1px solid ${({ theme }) => theme.palette.primary.main}; .MuiSvgIcon-root { @@ -50,9 +48,30 @@ const SelectedOption = styled(OptionWrapper)` } `; +const Label = styled.span` + font-style: ${props => props.theme.typography.fontWeightBold}; + color: ${props => props.theme.palette.text.primary}; +`; + +const Code = styled.span` + margin-inline: 0.45rem; + padding-left: 0.45rem; + border-left: 1px solid ${props => props.theme.palette.text.secondary}; + color: ${props => props.theme.palette.text.secondary}; + flex: 1; +`; + const DisplayOption = ({ option, state }) => { const { selected } = state; - const label = typeof option === 'string' ? option : option.label || option.value; + const label = + typeof option === 'string' ? ( + option + ) : ( + <> + + {option.secondaryLabel ? {option.secondaryLabel} : null} + + ); if (selected) return ( @@ -66,9 +85,9 @@ const DisplayOption = ({ option, state }) => { export const Autocomplete = styled(BaseAutocomplete).attrs(props => ({ muiProps: { - ...(props.muiProps || {}), renderOption: (option, state) => , PaperComponent: StyledPaper, + ...(props.muiProps || {}), }, }))` width: 100%; diff --git a/packages/datatrak-web/src/features/Reports/Inputs/EntitySelectorInput.tsx b/packages/datatrak-web/src/features/Reports/Inputs/EntitySelectorInput.tsx index a89db6d4dc..f6c57e8621 100644 --- a/packages/datatrak-web/src/features/Reports/Inputs/EntitySelectorInput.tsx +++ b/packages/datatrak-web/src/features/Reports/Inputs/EntitySelectorInput.tsx @@ -62,10 +62,10 @@ export const EntitySelectorInput = ({ selectedEntityLevel }: EntitySelectorInput id={selectedEntityLevel} getOptionSelected={(option, selected) => option.value === selected.value} options={ - entities?.map(({ name: entityName, id, code, type: entityType }) => ({ + entities?.map(({ name: entityName, id, code: secondaryLabel, type: entityType }) => ({ label: entityName, value: id, - code, + secondaryLabel, type: entityType, })) ?? [] } diff --git a/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx b/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx index 70e5fc380d..6b239884c8 100644 --- a/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx +++ b/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx @@ -96,12 +96,13 @@ export const SurveyQuestion = ({ const defaultValue = getDefaultValue(); - // display the entity error in it's own component because otherwise it will end up at the bottom of the big list of entities - const displayError = errors[name] && errors[name].message && !type.includes('Entity'); - // Use a Controller so that the fields that require change handlers, values, etc work with react-hook-form, which is uncontrolled by default + // Display the entity error in its own component because otherwise it will end up at the bottom of the big list of entities + const displayError = errors[name]?.message && !type.includes('Entity'); return ( + {/* Use a Controller so that the fields that require change handlers, values, etc. work with + react-hook-form, which is uncontrolled by default */} void; }; }; - if (firstError && firstError?.ref) { - firstError?.ref?.focus(); + if (firstError?.ref) { + firstError.ref.focus(); } } }, [JSON.stringify(errors)]); diff --git a/packages/datatrak-web/src/features/Survey/Screens/SurveyReviewScreen.tsx b/packages/datatrak-web/src/features/Survey/Screens/SurveyReviewScreen.tsx index 5890f86ec9..cefcb9dcc2 100644 --- a/packages/datatrak-web/src/features/Survey/Screens/SurveyReviewScreen.tsx +++ b/packages/datatrak-web/src/features/Survey/Screens/SurveyReviewScreen.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 { Typography } from '@material-ui/core'; @@ -41,7 +41,7 @@ export const SurveyReviewScreen = () => { Review and submit Please review your survey answers below. To edit any answers, please navigate back using - the 'Back' button below. Once submitted, your survey answers will be uploaded to Tupaia.{' '} + the ‘Back’ button below. Once submitted, your survey answers will be uploaded to Tupaia. diff --git a/packages/datatrak-web/src/features/Survey/utils.ts b/packages/datatrak-web/src/features/Survey/utils.ts index 08b9036119..3d8896aeb2 100644 --- a/packages/datatrak-web/src/features/Survey/utils.ts +++ b/packages/datatrak-web/src/features/Survey/utils.ts @@ -2,8 +2,16 @@ * Tupaia * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -import { QuestionType } from "@tupaia/types"; -import { SurveyScreen } from "../../types"; +import { QuestionType } from '@tupaia/types'; +import { SurveyScreen } from '../../types'; + +const validateSurveyComponent = component => { + if (component.type === QuestionType.PrimaryEntity && !component.config?.entity?.createNew) { + component.validationCriteria = component.validationCriteria ?? {}; + component.validationCriteria.mandatory = true; + } + return component; +}; export const READ_ONLY_QUESTION_TYPES = [ QuestionType.Condition, @@ -11,22 +19,19 @@ export const READ_ONLY_QUESTION_TYPES = [ QuestionType.Instruction, QuestionType.CodeGenerator, ]; + export const getSurveyScreenNumber = (screens, screen) => { if (!screen) return null; const { surveyScreenComponents, id } = screen; const nonInstructionScreens = - screens?.filter((screen) => - screen.surveyScreenComponents.some( - (component) => component.type !== QuestionType.Instruction - ) + screens?.filter(screen => + screen.surveyScreenComponents.some(component => component.type !== QuestionType.Instruction), ) ?? []; const screenNumber = surveyScreenComponents.some( - (component) => component.type !== QuestionType.Instruction + component => component.type !== QuestionType.Instruction, ) - ? nonInstructionScreens.findIndex( - (nonInstructionScreen) => nonInstructionScreen.id === id - ) + 1 + ? nonInstructionScreens.findIndex(nonInstructionScreen => nonInstructionScreen.id === id) + 1 : null; return screenNumber; @@ -35,22 +40,19 @@ export const getSurveyScreenNumber = (screens, screen) => { export const getAllSurveyComponents = (surveyScreens?: SurveyScreen[]) => { return ( surveyScreens - ?.map(({ surveyScreenComponents }) => surveyScreenComponents) - ?.flat() ?? [] + ?.flatMap(({ surveyScreenComponents }) => surveyScreenComponents) + .map(validateSurveyComponent) ?? [] ); }; export const getErrorsByScreen = ( errors: Record>, - visibleScreens?: SurveyScreen[] + visibleScreens?: SurveyScreen[], ) => { return ( Object.entries(errors).reduce((acc, [questionName, error]) => { - const screenIndex = visibleScreens?.findIndex( - ({ surveyScreenComponents }) => - surveyScreenComponents.find( - (question) => question.questionId === questionName - ) + const screenIndex = visibleScreens?.findIndex(({ surveyScreenComponents }) => + surveyScreenComponents.find(question => question.questionId === questionName), ); if (screenIndex === undefined || screenIndex === -1) return acc; diff --git a/packages/server-utils/src/downloadPageAsPDF.ts b/packages/server-utils/src/downloadPageAsPDF.ts index e427182713..97c7a42faf 100644 --- a/packages/server-utils/src/downloadPageAsPDF.ts +++ b/packages/server-utils/src/downloadPageAsPDF.ts @@ -42,6 +42,7 @@ export const downloadPageAsPDF = async ( pdfPageUrl: string, userCookie = '', cookieDomain: string | undefined, + landscape = false, ) => { let browser; let buffer; @@ -55,6 +56,7 @@ export const downloadPageAsPDF = async ( buffer = await page.pdf({ format: 'a4', printBackground: true, + landscape, }); } 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 3222b4e3ce..aa6cbd3dc7 100644 --- a/packages/tupaia-web-server/src/app/createApp.ts +++ b/packages/tupaia-web-server/src/app/createApp.ts @@ -51,6 +51,10 @@ export async function createApp(db: TupaiaDatabase = new TupaiaDatabase()) { 'dashboards/:projectCode/:entityCode/:dashboardCode/export', handleWith(routes.ExportDashboardRoute), ) + .post( + 'mapOverlays/:projectCode/:entityCode/:mapOverlayCode/export', + handleWith(routes.ExportMapOverlayRoute), + ) .post( 'dashboards/:projectCode/:entityCode/:dashboardCode/email', handleWith(routes.EmailDashboardRoute), diff --git a/packages/tupaia-web-server/src/routes/ExportMapOverlayRoute.ts b/packages/tupaia-web-server/src/routes/ExportMapOverlayRoute.ts new file mode 100644 index 0000000000..1aa7490b89 --- /dev/null +++ b/packages/tupaia-web-server/src/routes/ExportMapOverlayRoute.ts @@ -0,0 +1,77 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + * + */ + +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { TupaiaWebExportMapOverlayRequest } from '@tupaia/types'; +import { stringifyQuery } from '@tupaia/utils'; +import { downloadPageAsPDF } from '@tupaia/server-utils'; + +export type ExportMapOverlayRequest = Request< + TupaiaWebExportMapOverlayRequest.Params, + TupaiaWebExportMapOverlayRequest.ResBody, + TupaiaWebExportMapOverlayRequest.ReqBody, + TupaiaWebExportMapOverlayRequest.ReqQuery +>; + +const downloadMapOverlayAsPdf = ( + projectCode: string, + entityCode: string, + mapOverlayCode: string, + baseUrl: TupaiaWebExportMapOverlayRequest.ReqBody['baseUrl'], + cookie: string, + cookieDomain: TupaiaWebExportMapOverlayRequest.ReqBody['cookieDomain'], + zoom: TupaiaWebExportMapOverlayRequest.ReqBody['zoom'], + center: TupaiaWebExportMapOverlayRequest.ReqBody['center'], + tileset: TupaiaWebExportMapOverlayRequest.ReqBody['tileset'], + hiddenValues: TupaiaWebExportMapOverlayRequest.ReqBody['hiddenValues'], + mapOverlayPeriod?: TupaiaWebExportMapOverlayRequest.ReqBody['mapOverlayPeriod'], + locale?: TupaiaWebExportMapOverlayRequest.ReqBody['locale'], +) => { + const endpoint = `${projectCode}/${entityCode}/map-overlay-pdf-export`; + const pdfPageUrl = stringifyQuery(baseUrl, endpoint, { + zoom, + center, + tileset, + hiddenValues, + overlay: mapOverlayCode, + overlayPeriod: mapOverlayPeriod, + locale, + }); + + return downloadPageAsPDF(pdfPageUrl, cookie, cookieDomain, true); +}; + +export class ExportMapOverlayRoute extends Route { + protected type = 'download' as const; + + public async buildResponse() { + const { projectCode, entityCode, mapOverlayCode } = this.req.params; + const { baseUrl, cookieDomain, zoom, center, tileset, hiddenValues, mapOverlayPeriod, locale } = + this.req.body; + const { cookie } = this.req.headers; + + if (!cookie) { + throw new Error(`Must have a valid session to export a map overlay`); + } + + const buffer = await downloadMapOverlayAsPdf( + projectCode, + entityCode, + mapOverlayCode, + baseUrl, + cookie, + cookieDomain, + zoom, + center, + tileset, + hiddenValues, + mapOverlayPeriod, + locale, + ); + return { contents: buffer, type: 'application/pdf' }; + } +} diff --git a/packages/tupaia-web-server/src/routes/SubscribeDashboardRoute.ts b/packages/tupaia-web-server/src/routes/SubscribeDashboardRoute.ts index 9166e3d69a..00c06d940a 100644 --- a/packages/tupaia-web-server/src/routes/SubscribeDashboardRoute.ts +++ b/packages/tupaia-web-server/src/routes/SubscribeDashboardRoute.ts @@ -82,7 +82,7 @@ export class SubscribeDashboardRoute extends Route { ); } - const [upsertedEntry] = await await ctx.services.central.fetchResources( + const [upsertedEntry] = await ctx.services.central.fetchResources( 'dashboardMailingListEntries', { filter: { diff --git a/packages/tupaia-web-server/src/routes/index.ts b/packages/tupaia-web-server/src/routes/index.ts index a64ea79379..9b90788b87 100644 --- a/packages/tupaia-web-server/src/routes/index.ts +++ b/packages/tupaia-web-server/src/routes/index.ts @@ -36,3 +36,5 @@ export { UnsubscribeDashboardMailingListRoute, UnsubscribeDashboardMailingListRequest, } from './UnsubscribeDashboardMailingListRoute'; + +export { ExportMapOverlayRequest, ExportMapOverlayRoute } from './ExportMapOverlayRoute'; diff --git a/packages/tupaia-web-server/src/utils/downloadDashboardAsPdf.ts b/packages/tupaia-web-server/src/utils/downloadDashboardAsPdf.ts index 48f520f832..b915a28ce1 100644 --- a/packages/tupaia-web-server/src/utils/downloadDashboardAsPdf.ts +++ b/packages/tupaia-web-server/src/utils/downloadDashboardAsPdf.ts @@ -20,7 +20,7 @@ export const downloadDashboardAsPdf = ( exportWithTable: false, }, ) => { - const endpoint = `${projectCode}/${entityCode}/${dashboardName}/pdf-export`; + const endpoint = `${projectCode}/${entityCode}/${dashboardName}/dashboard-pdf-export`; const pdfPageUrl = stringifyQuery(baseUrl, endpoint, { selectedDashboardItems: selectedDashboardItems?.join(','), settings: JSON.stringify(settings), diff --git a/packages/tupaia-web-server/src/utils/generateFrontendExcludedFilter.ts b/packages/tupaia-web-server/src/utils/generateFrontendExcludedFilter.ts index 25ab251da8..61a767e214 100644 --- a/packages/tupaia-web-server/src/utils/generateFrontendExcludedFilter.ts +++ b/packages/tupaia-web-server/src/utils/generateFrontendExcludedFilter.ts @@ -21,7 +21,7 @@ export const getTypesToExclude = async ( if (config?.frontendExcluded) { const typesFilter = []; - for (const excludedConfig of config?.frontendExcluded) { + for (const excludedConfig of config.frontendExcluded) { const { types, exceptions } = excludedConfig; const exceptedPermissionGroups = exceptions?.permissionGroups ?? []; diff --git a/packages/tupaia-web/package.json b/packages/tupaia-web/package.json index 619eef6131..6189e31a59 100644 --- a/packages/tupaia-web/package.json +++ b/packages/tupaia-web/package.json @@ -39,6 +39,7 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-hook-form": "^6.15.1", + "react-leaflet": "^3.2.1", "react-query": "^3.39.3", "react-router": "6.3.0", "react-router-dom": "6.3.0", diff --git a/packages/tupaia-web/src/Routes.tsx b/packages/tupaia-web/src/Routes.tsx index 05fbb7af29..9365bd2a1c 100644 --- a/packages/tupaia-web/src/Routes.tsx +++ b/packages/tupaia-web/src/Routes.tsx @@ -4,9 +4,15 @@ */ import React from 'react'; import { Navigate, Route, Routes as RouterRoutes, useLocation } from 'react-router-dom'; -import { LandingPage, PDFExport, ProjectPage, Unsubscribe } from './views'; +import { + DashboardPDFExport, + LandingPage, + MapOverlayPDFExport, + ProjectPage, + Unsubscribe, +} from './views'; import { Dashboard } from './features'; -import { MODAL_ROUTES, DEFAULT_URL, ROUTE_STRUCTURE } from './constants'; +import { MODAL_ROUTES, DEFAULT_URL, ROUTE_STRUCTURE, MAP_OVERLAY_EXPORT_ROUTE } from './constants'; import { useUser } from './api/queries'; import { MainLayout } from './layout'; import { LoadingScreen } from './components'; @@ -63,7 +69,11 @@ export const Routes = () => { return ( - } /> + } + /> + } /> } /> }> } /> diff --git a/packages/tupaia-web/src/api/mutations/index.ts b/packages/tupaia-web/src/api/mutations/index.ts index 61105bf695..fe31873a1b 100644 --- a/packages/tupaia-web/src/api/mutations/index.ts +++ b/packages/tupaia-web/src/api/mutations/index.ts @@ -20,3 +20,4 @@ export { useSubscribeDashboard } from './useSubscribeDashboard'; export { useUnsubscribeDashboard } from './useUnsubscribeDashboard'; export { useUnsubscribeDashboardMailingList } from './useUnsubscribeDashboardMailingList'; export { useEmailDashboard } from './useEmailDashboard'; +export { useExportMapOverlay } from './useExportMapOverlay'; diff --git a/packages/tupaia-web/src/api/mutations/useDownloadFiles.ts b/packages/tupaia-web/src/api/mutations/useDownloadFiles.ts index 2353d21c32..c9e4ef7f5d 100644 --- a/packages/tupaia-web/src/api/mutations/useDownloadFiles.ts +++ b/packages/tupaia-web/src/api/mutations/useDownloadFiles.ts @@ -1,7 +1,8 @@ /* * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ + import { useMutation } from 'react-query'; import downloadJs from 'downloadjs'; import { getUniqueFileNameParts } from '@tupaia/utils'; @@ -28,7 +29,7 @@ export const useDownloadFiles = () => { }), { onSuccess: async (data: any, uniqueFileNames: string[]) => { - let fileName = + const fileName = uniqueFileNames.length === 1 ? getUniqueFileNameParts(uniqueFileNames[0]).fileName : getMultiFileDownloadFileName(); diff --git a/packages/tupaia-web/src/api/mutations/useExportDashboard.tsx b/packages/tupaia-web/src/api/mutations/useExportDashboard.tsx index 7a7359e9fe..550b452b96 100644 --- a/packages/tupaia-web/src/api/mutations/useExportDashboard.tsx +++ b/packages/tupaia-web/src/api/mutations/useExportDashboard.tsx @@ -6,6 +6,7 @@ import { useMutation } from 'react-query'; import { TupaiaWebExportDashboardRequest } from '@tupaia/types'; import { API_URL, post } from '../api'; import { DashboardName, EntityCode, ProjectCode } from '../../types'; +import { downloadPDF } from '../../utils'; type ExportDashboardBody = { projectCode?: ProjectCode; @@ -16,7 +17,7 @@ type ExportDashboardBody = { }; // Requests a dashboard PDF export from the server, and returns the response -export const useExportDashboard = ({ onSuccess }: { onSuccess?: (data: Blob) => void }) => { +export const useExportDashboard = (fileName: string) => { return useMutation( ({ projectCode, @@ -41,7 +42,9 @@ export const useExportDashboard = ({ onSuccess }: { onSuccess?: (data: Blob) => }); }, { - onSuccess, + onSuccess: data => { + downloadPDF(data, fileName); + }, }, ); }; diff --git a/packages/tupaia-web/src/api/mutations/useExportMapOverlay.ts b/packages/tupaia-web/src/api/mutations/useExportMapOverlay.ts new file mode 100644 index 0000000000..3abefa0d4c --- /dev/null +++ b/packages/tupaia-web/src/api/mutations/useExportMapOverlay.ts @@ -0,0 +1,60 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { useMutation } from 'react-query'; +import { LatLng } from 'leaflet'; +import { MapOverlay } from '@tupaia/types'; +import { LegendProps } from '@tupaia/ui-map-components'; +import { EntityCode, ProjectCode } from '../../types'; +import { API_URL, post } from '../api'; +import { downloadPDF } from '../../utils'; + +type ExportDashboardBody = { + projectCode?: ProjectCode; + entityCode?: EntityCode; + mapOverlayCode?: MapOverlay['code']; + zoom: number; + center: LatLng; + hiddenValues: LegendProps['hiddenValues']; + tileset: string; + mapOverlayPeriod?: string; +}; + +// Requests a map overlay PDF export from the server, and returns the response +export const useExportMapOverlay = (fileName: string) => { + return useMutation( + ({ + projectCode, + entityCode, + mapOverlayCode, + zoom, + center, + hiddenValues, + tileset, + mapOverlayPeriod, + }: ExportDashboardBody) => { + // Auth cookies are saved against this domain. Pass this to server, so that when it pretends to be us, it can do the same. + const cookieDomain = new URL(API_URL).hostname; + + return post(`mapOverlays/${projectCode}/${entityCode}/${mapOverlayCode}/export`, { + responseType: 'blob', + data: { + cookieDomain, + baseUrl: window.location.origin, + zoom, + center: JSON.stringify(center), + hiddenValues: JSON.stringify(hiddenValues), + tileset, + mapOverlayPeriod, + locale: window.navigator?.language || 'en-AU', + }, + }); + }, + { + onSuccess: data => { + downloadPDF(data, fileName); + }, + }, + ); +}; diff --git a/packages/tupaia-web/src/constants/url.ts b/packages/tupaia-web/src/constants/url.ts index 1236da1a50..d643f53eb5 100644 --- a/packages/tupaia-web/src/constants/url.ts +++ b/packages/tupaia-web/src/constants/url.ts @@ -36,3 +36,5 @@ export const DEFAULT_PERIOD_PARAM_STRING = 'DEFAULT_PERIOD'; export const DEFAULT_MAP_OVERLAY_ID = '126'; // 'Operational Facilities' export const ROUTE_STRUCTURE = '/:projectCode/:entityCode/:dashboardName'; + +export const MAP_OVERLAY_EXPORT_ROUTE = '/:projectCode/:entityCode/map-overlay-pdf-export'; diff --git a/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportConfig.tsx b/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportConfig.tsx index 6c1df10ccd..0de00fbd73 100644 --- a/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportConfig.tsx +++ b/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportConfig.tsx @@ -6,7 +6,6 @@ import React from 'react'; import styled from 'styled-components'; import { useParams } from 'react-router'; -import downloadJs from 'downloadjs'; import { Button, LoadingContainer } from '@tupaia/ui-components'; import { useEntity, useProject } from '../../../api/queries'; import { useExportDashboard } from '../../../api/mutations'; @@ -112,21 +111,10 @@ export const ExportConfig = ({ onClose, selectedDashboardItems }: ExportDashboar const { activeDashboard } = useDashboard(); const { exportWithLabels, exportWithTable } = useExportSettings(); - const handleExportSuccess = (data: Blob) => { - downloadJs(data, `${exportFileName}.pdf`); - }; - - const { - mutate: requestPdfExport, - error, - isLoading, - reset, - } = useExportDashboard({ - onSuccess: handleExportSuccess, - }); - const exportFileName = `${project?.name}-${entity?.name}-${dashboardName}-dashboard-export`; + const { mutate: requestPdfExport, error, isLoading, reset } = useExportDashboard(exportFileName); + const handleExport = () => requestPdfExport({ projectCode, diff --git a/packages/tupaia-web/src/features/Dashboard/ExportDashboard/Preview.tsx b/packages/tupaia-web/src/features/Dashboard/ExportDashboard/Preview.tsx index f92eb6ea21..955c1f0df0 100644 --- a/packages/tupaia-web/src/features/Dashboard/ExportDashboard/Preview.tsx +++ b/packages/tupaia-web/src/features/Dashboard/ExportDashboard/Preview.tsx @@ -7,7 +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 { PDFExport } from '../../../views'; +import { DashboardPDFExport } from '../../../views'; import { MOBILE_BREAKPOINT } from '../../../constants'; const PreviewPanelContainer = styled.div` @@ -74,7 +74,7 @@ export const Preview = ({ selectedDashboardItems }: { selectedDashboardItems: st /> - + ); diff --git a/packages/tupaia-web/src/features/Map/Map.tsx b/packages/tupaia-web/src/features/Map/Map.tsx index 17f3e3cc22..10a37cf709 100644 --- a/packages/tupaia-web/src/features/Map/Map.tsx +++ b/packages/tupaia-web/src/features/Map/Map.tsx @@ -3,26 +3,24 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import styled from 'styled-components'; import { useParams } from 'react-router-dom'; -import { - TileLayer, - LeafletMap, - ZoomControl, - TilePicker, - getAutoTileSet, - DEFAULT_TILESETS, -} from '@tupaia/ui-map-components'; +import { TileLayer, LeafletMap, ZoomControl, TilePicker } from '@tupaia/ui-map-components'; import { ErrorBoundary } from '@tupaia/ui-components'; -import { useProject } from '../../api/queries'; -import { useGAEffect } from '../../utils'; -import { CUSTOM_TILE_SETS, MOBILE_BREAKPOINT } from '../../constants'; +import { MOBILE_BREAKPOINT } from '../../constants'; import { MapWatermark } from './MapWatermark'; import { MapLegend } from './MapLegend'; import { MapOverlaySelector } from './MapOverlaySelector'; import { MapOverlaysLayer } from './MapOverlaysLayer'; -import { useHiddenMapValues, useDefaultMapOverlay, useMapOverlayMapData } from './utils'; +import { + useHiddenMapValues, + useDefaultMapOverlay, + useMapOverlayMapData, + MapContextProvider, + useMapContext, + useTilesets, +} from './utils'; import { DemoLand } from './DemoLand'; const MapContainer = styled.div` @@ -108,46 +106,39 @@ const MapControlColumn = styled.div` } `; -const useTileSets = () => { - const { projectCode } = useParams(); - const { data: project } = useProject(projectCode); - const initialTileSet = getAutoTileSet(); - const [activeTileSet, setActiveTileSet] = useState(initialTileSet); - const { tileSets = '' } = project?.config || {}; - const customTilesetNames = tileSets?.split(',') || []; - const customTileSets = customTilesetNames - .map( - tileset => - CUSTOM_TILE_SETS.find(({ key }) => key === tileset) as (typeof CUSTOM_TILE_SETS)[0], - ) - .filter(item => item); - const defaultTileSets = [DEFAULT_TILESETS.osm, DEFAULT_TILESETS.satellite]; - const availableTileSets = [...defaultTileSets, ...customTileSets]; - - useGAEffect('Map', 'Change Tile Set', activeTileSet?.label); - - const onTileSetChange = (tileSetKey: string) => { - const newActiveTileSet = availableTileSets.find( - ({ key }) => key === tileSetKey, - ) as (typeof CUSTOM_TILE_SETS)[0]; - setActiveTileSet(newActiveTileSet); - }; - - useEffect(() => { - if ( - activeTileSet && - availableTileSets.length && - !availableTileSets.some(({ key }) => key === activeTileSet.key) - ) { - setActiveTileSet(initialTileSet); - } - }, [JSON.stringify(availableTileSets)]); +// Separate the map component from the context provider so that we can set the map on creation using the context +const MapInner = () => { + const { setMap } = useMapContext(); + // Setup legend hidden values + const { serieses } = useMapOverlayMapData(); + const { hiddenValues, setValueHidden } = useHiddenMapValues(serieses); - return { - availableTileSets, - activeTileSet, - onTileSetChange, - }; + const { availableTileSets, activeTileSet, onTileSetChange } = useTilesets(); + return ( + + + + + + + + + {/* Map Controls need to be outside the map so that the mouse events on controls don't interfere with the map */} + + + + + + + + + + + ); }; export const Map = () => { @@ -155,37 +146,11 @@ export const Map = () => { useDefaultMapOverlay(projectCode, entityCode); - // Setup legend hidden values - const { serieses } = useMapOverlayMapData(); - const { hiddenValues, setValueHidden } = useHiddenMapValues(serieses); - - const { availableTileSets, activeTileSet, onTileSetChange } = useTileSets(); - return ( - - - - - - - - - {/* Map Controls need to be outside the map so that the mouse events on controls don't interfere with the map */} - - - - - - - - - - + + + ); }; diff --git a/packages/tupaia-web/src/features/Map/MapLegend/MapLegend.tsx b/packages/tupaia-web/src/features/Map/MapLegend/MapLegend.tsx index de8320be47..b68b323685 100644 --- a/packages/tupaia-web/src/features/Map/MapLegend/MapLegend.tsx +++ b/packages/tupaia-web/src/features/Map/MapLegend/MapLegend.tsx @@ -4,13 +4,13 @@ */ import React from 'react'; -import { Legend, LegendProps } from '@tupaia/ui-map-components'; -import { MobileMapLegend } from './MobileMapLegend'; import { useSearchParams } from 'react-router-dom'; -import { MOBILE_BREAKPOINT, URL_SEARCH_PARAMS } from '../../../constants'; -import { useMapOverlayMapData } from '../utils'; import styled from 'styled-components'; import { ErrorBoundary } from '@tupaia/ui-components'; +import { Legend as LegendComponent, LegendProps } from '@tupaia/ui-map-components'; +import { MOBILE_BREAKPOINT, URL_SEARCH_PARAMS } from '../../../constants'; +import { useMapOverlayMapData } from '../utils'; +import { MobileMapLegend } from './MobileMapLegend'; const DesktopWrapper = styled.div` pointer-events: auto; @@ -35,7 +35,7 @@ const SeriesDivider = styled.div` } `; -export const MapLegend = ({ hiddenValues, setValueHidden }: LegendProps) => { +export const Legend = ({ hiddenValues, setValueHidden, isExport }: LegendProps) => { const [urlSearchParams] = useSearchParams(); const selectedOverlay = urlSearchParams.get(URL_SEARCH_PARAMS.MAP_OVERLAY); const { isLoading, isFetched, ...overlayReportData } = useMapOverlayMapData(); @@ -43,25 +43,27 @@ export const MapLegend = ({ hiddenValues, setValueHidden }: LegendProps) => { if (!selectedOverlay || !overlayReportData || isLoading) { return null; } - - const LegendComponent = () => ( - ); +}; +export const MapLegend = ({ hiddenValues, setValueHidden }: LegendProps) => { return ( - + - + ); diff --git a/packages/tupaia-web/src/features/Map/MapLegend/index.ts b/packages/tupaia-web/src/features/Map/MapLegend/index.ts index 968a46071c..83621ce3d2 100644 --- a/packages/tupaia-web/src/features/Map/MapLegend/index.ts +++ b/packages/tupaia-web/src/features/Map/MapLegend/index.ts @@ -3,4 +3,4 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -export { MapLegend } from './MapLegend'; +export { MapLegend, Legend } from './MapLegend'; diff --git a/packages/tupaia-web/src/features/Map/MapOverlaySelector/DesktopMapOverlaySelector.tsx b/packages/tupaia-web/src/features/Map/MapOverlaySelector/DesktopMapOverlaySelector.tsx index 9559b6650a..813f5cc0f5 100644 --- a/packages/tupaia-web/src/features/Map/MapOverlaySelector/DesktopMapOverlaySelector.tsx +++ b/packages/tupaia-web/src/features/Map/MapOverlaySelector/DesktopMapOverlaySelector.tsx @@ -4,27 +4,40 @@ */ import React, { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import moment from 'moment'; import styled from 'styled-components'; -import { useParams } from 'react-router'; -import { periodToMoment } from '@tupaia/utils'; -import { Tooltip } from '@tupaia/ui-components'; -import { IconButton } from '@tupaia/ui-components'; -import { ArrowDropDown, Layers, Assignment } from '@material-ui/icons'; -import { Accordion, Typography, AccordionSummary, AccordionDetails } from '@material-ui/core'; -import { useMapOverlayMapData } from '../utils'; +import { GRANULARITY_CONFIG, periodToMoment } from '@tupaia/utils'; +import { Tooltip, IconButton, SmallAlert } from '@tupaia/ui-components'; +import { LegendProps } from '@tupaia/ui-map-components'; +import { ArrowDropDown, Layers, Assignment, GetApp, Close } from '@material-ui/icons'; +import { + Accordion, + Typography, + AccordionSummary, + AccordionDetails, + CircularProgress, +} from '@material-ui/core'; +import { useMapOverlayMapData, useMapContext } from '../utils'; import { Entity } from '../../../types'; -import { useMapOverlays } from '../../../api/queries'; -import { MOBILE_BREAKPOINT } from '../../../constants'; -import { useGAEffect } from '../../../utils'; +import { useExportMapOverlay } from '../../../api/mutations'; +import { useEntity, useMapOverlays, useProject, useUser } from '../../../api/queries'; +import { MOBILE_BREAKPOINT, URL_SEARCH_PARAMS } from '../../../constants'; +import { convertDateRangeToUrlPeriodString, useDateRanges, useGAEffect } from '../../../utils'; import { MapTableModal } from './MapTableModal'; import { MapOverlayList } from './MapOverlayList'; import { MapOverlayDatePicker } from './MapOverlayDatePicker'; import { MapOverlaySelectorTitle } from './MapOverlaySelectorTitle'; -const MapTableButton = styled(IconButton)` - margin: -0.625rem -0.625rem -0.625rem 0; - padding: 0.5rem 0.325rem 0.5rem 0.75rem; - color: white; +const MapButton = styled(IconButton)` + color: ${({ theme }) => theme.palette.text.primary}; + padding: 0.1rem; + & + & { + margin-inline-start: 0.5rem; + } + .MuiSvgIcon-root { + font-size: 1.3rem; + } `; const MaxHeightContainer = styled.div` @@ -47,7 +60,7 @@ const Header = styled.div` display: flex; justify-content: space-between; align-items: center; - padding: 0.9rem 1rem; + padding: 0.8rem 1rem; background-color: ${({ theme }) => theme.palette.secondary.main}; border-radius: 5px 5px 0 0; pointer-events: auto; @@ -61,12 +74,6 @@ const Heading = styled(Typography).attrs({ font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; `; -const TableAssignmentIcon = styled(Assignment)` - margin-right: 0.5rem; - width: 1.2rem; - cursor: pointer; -`; - const Container = styled(MaxHeightContainer)` border-radius: 0 0 5px 5px; // Set pointer events on the container rather than higher up so that it only applies to the open menu @@ -153,19 +160,69 @@ const LatestDataText = styled(Typography)` line-height: 1.3; `; +const LoadingSpinner = styled(CircularProgress).attrs({ + size: 16, +})` + color: ${({ theme }) => theme.palette.text.primary}; +`; + +const ErrorAlert = styled(SmallAlert)` + padding-inline-end: 0.5rem; + .MuiAlert-message { + display: flex; + position: relative; + span { + width: 85%; + } + } +`; + +const ErrorCloseButton = styled(IconButton)` + position: absolute; + top: 0; + right: 0; + padding: 0.2rem; + color: ${({ theme }) => theme.palette.text.primary}; +`; + interface DesktopMapOverlaySelectorProps { entityName?: Entity['name']; overlayLibraryOpen: boolean; toggleOverlayLibrary: () => void; + hiddenValues: LegendProps['hiddenValues']; + activeTileSet: { + key: string; + label: string; + thumbnail: string; + url: string; + }; } export const DesktopMapOverlaySelector = ({ overlayLibraryOpen, toggleOverlayLibrary, + hiddenValues, + activeTileSet, }: DesktopMapOverlaySelectorProps) => { const { projectCode, entityCode } = useParams(); const { hasMapOverlays, selectedOverlay } = useMapOverlays(projectCode, entityCode); + const { data: project } = useProject(projectCode); + const { data: entity } = useEntity(projectCode, entityCode); const { period } = useMapOverlayMapData(); + const { map } = useMapContext(); + const { isLoggedIn } = useUser(); + const exportFileName = `${project?.name}-${entity?.name}-${selectedOverlay?.code}-map-overlay-export`; + const { + mutate: exportMapOverlay, + isLoading: isExporting, + error, + reset, + } = useExportMapOverlay(exportFileName); + const { startDate, endDate } = useDateRanges( + URL_SEARCH_PARAMS.MAP_OVERLAY_PERIOD, + selectedOverlay, + ); + const [mapModalOpen, setMapModalOpen] = useState(false); // This only fires when the selected overlay changes. Because this is always rendered, as is the mobile overlay selector, we only need this in one place useGAEffect('MapOverlays', 'Change', selectedOverlay?.name); @@ -173,6 +230,47 @@ export const DesktopMapOverlaySelector = ({ setMapModalOpen(!mapModalOpen); }; + // Pass the explicit date range to the export function, because the server may not have the correct time zone when the default date range is used + const getMapOverlayPeriodForExport = (): string | undefined => { + const periodGranularity = GRANULARITY_CONFIG[ + selectedOverlay?.periodGranularity as keyof typeof GRANULARITY_CONFIG + ]?.momentUnit as moment.unitOfTime.StartOf; + // if the overlay has no period granularity, return undefined + if (!periodGranularity) return undefined; + const periodStartDate = moment(startDate).startOf(periodGranularity); + const periodEndDate = moment(endDate).endOf(periodGranularity); + const urlPeriodString = convertDateRangeToUrlPeriodString({ + startDate: periodStartDate, + endDate: periodEndDate, + }); + return urlPeriodString; + }; + + const onExportMapOverlay = () => { + if (!map) throw new Error('Map is not ready'); + const urlPeriodString = getMapOverlayPeriodForExport(); + exportMapOverlay({ + projectCode, + entityCode, + mapOverlayCode: selectedOverlay?.code, + center: map.getCenter(), + zoom: map.getZoom(), + hiddenValues, + tileset: activeTileSet.url, + mapOverlayPeriod: urlPeriodString, + }); + }; + + const getExportTooltip = () => { + if (isExporting) { + return ''; + } + + return 'Export map overlay as PDF'; + }; + + const exportTooltip = getExportTooltip(); + return ( <> {mapModalOpen && } @@ -180,15 +278,36 @@ export const DesktopMapOverlaySelector = ({
Map Overlays {selectedOverlay && ( - - - - - +
+ {isLoggedIn && ( + + {isExporting ? ( + + ) : ( + + + + )} + + )} + + + + + +
)}
+ {error && ( + + {error.message} + + + + + )} diff --git a/packages/tupaia-web/src/features/Map/MapOverlaySelector/MapOverlaySelector.tsx b/packages/tupaia-web/src/features/Map/MapOverlaySelector/MapOverlaySelector.tsx index 51c58290b9..6b41b65e6a 100644 --- a/packages/tupaia-web/src/features/Map/MapOverlaySelector/MapOverlaySelector.tsx +++ b/packages/tupaia-web/src/features/Map/MapOverlaySelector/MapOverlaySelector.tsx @@ -5,10 +5,22 @@ import React, { useState } from 'react'; import { ErrorBoundary } from '@tupaia/ui-components'; +import { LegendProps } from '@tupaia/ui-map-components'; import { DesktopMapOverlaySelector } from './DesktopMapOverlaySelector'; import { MobileMapOverlaySelector } from './MobileMapOverlaySelector'; -export const MapOverlaySelector = () => { +export const MapOverlaySelector = ({ + hiddenValues, + activeTileSet, +}: { + hiddenValues: LegendProps['hiddenValues']; + activeTileSet: { + key: string; + label: string; + thumbnail: string; + url: string; + }; +}) => { const [overlayLibraryOpen, setOverlayLibraryOpen] = useState(false); const toggleOverlayLibrary = () => { @@ -24,6 +36,8 @@ export const MapOverlaySelector = () => { ); diff --git a/packages/tupaia-web/src/features/Map/MapOverlaysLayer/MapOverlaysLayer.tsx b/packages/tupaia-web/src/features/Map/MapOverlaysLayer/MapOverlaysLayer.tsx index ea8433f892..8982db5802 100644 --- a/packages/tupaia-web/src/features/Map/MapOverlaysLayer/MapOverlaysLayer.tsx +++ b/packages/tupaia-web/src/features/Map/MapOverlaysLayer/MapOverlaysLayer.tsx @@ -4,10 +4,11 @@ */ import React, { useEffect } from 'react'; -import { useParams } from 'react-router'; +import { useMatch, useParams } from 'react-router'; import { useMap } from 'react-leaflet'; import { LegendProps } from '@tupaia/ui-map-components'; import { useEntity } from '../../../api/queries'; +import { MAP_OVERLAY_EXPORT_ROUTE } from '../../../constants'; import { useMapOverlayMapData } from '../utils'; import { PolygonLayer } from './PolygonLayer'; import { MarkerLayer } from './MarkerLayer'; @@ -20,9 +21,11 @@ const useZoomToEntity = () => { const { data: entity } = useEntity(projectCode, entityCode); const map = useMap(); + const isExport = !!useMatch(MAP_OVERLAY_EXPORT_ROUTE); + // This is a replacement for the map positioning being handled in the ui-map-components LeafletMap file. We are doing this because we need access to the user's current zoom level, and are also slowly moving away from class based components to use hooks instead. useEffect(() => { - if (!entity || !map || (!entity.point && !entity.bounds && !entity.region)) return; + if (!entity || !map || (!entity.point && !entity.bounds && !entity.region) || isExport) return; if (entity.bounds) { map.flyToBounds(entity.bounds, { diff --git a/packages/tupaia-web/src/features/Map/index.ts b/packages/tupaia-web/src/features/Map/index.ts index b7d03570cf..8cfd6e227e 100644 --- a/packages/tupaia-web/src/features/Map/index.ts +++ b/packages/tupaia-web/src/features/Map/index.ts @@ -4,3 +4,6 @@ */ export { Map } from './Map'; +export { MapOverlaysLayer } from './MapOverlaysLayer'; +export { Legend } from './MapLegend'; +export { useMapOverlayMapData } from './utils'; diff --git a/packages/tupaia-web/src/features/Map/utils/index.ts b/packages/tupaia-web/src/features/Map/utils/index.ts index 27c66e9edf..c315723182 100644 --- a/packages/tupaia-web/src/features/Map/utils/index.ts +++ b/packages/tupaia-web/src/features/Map/utils/index.ts @@ -8,3 +8,5 @@ export { useHiddenMapValues } from './useHiddenMapValues'; export { useMapOverlayTableData } from './useMapOverlayTableData'; export { useMapOverlayMapData } from './useMapOverlayMapData'; export { useNavigateToEntity } from './useNavigateToEntity'; +export { useMapContext, MapContextProvider } from './mapContext'; +export { useTilesets } from './useTilesets'; diff --git a/packages/tupaia-web/src/features/Map/utils/mapContext.tsx b/packages/tupaia-web/src/features/Map/utils/mapContext.tsx new file mode 100644 index 0000000000..148d37b4d9 --- /dev/null +++ b/packages/tupaia-web/src/features/Map/utils/mapContext.tsx @@ -0,0 +1,24 @@ +import React, { ReactNode, useContext } from 'react'; +import { Map } from 'leaflet'; +import { createContext, useState } from 'react'; + +const defaultMapContext = { + map: null, + setMap: () => {}, +} as { + map: Map | null; + setMap: (map: Map | null) => void; +}; + +const MapContext = createContext(defaultMapContext); + +/** + * This is a context for the leaflet map. It is a workaround for the fact that we can't use react-leaflet's `useMap` inside the map overlay selector because the selector needs to be rendered outside of the map component to avoid interfering with the map's mouse events. + */ +export const MapContextProvider = ({ children }: { children: ReactNode }) => { + const [map, setMap] = useState(null); + + return {children}; +}; + +export const useMapContext = () => useContext(MapContext); diff --git a/packages/tupaia-web/src/features/Map/utils/useTilesets.tsx b/packages/tupaia-web/src/features/Map/utils/useTilesets.tsx new file mode 100644 index 0000000000..f74e9eb00a --- /dev/null +++ b/packages/tupaia-web/src/features/Map/utils/useTilesets.tsx @@ -0,0 +1,53 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router'; +import { DEFAULT_TILESETS, getAutoTileSet } from '@tupaia/ui-map-components'; +import { CUSTOM_TILE_SETS } from '../../../constants'; +import { useProject } from '../../../api/queries'; +import { useGAEffect } from '../../../utils'; + +export const useTilesets = () => { + const { projectCode } = useParams(); + const { data: project } = useProject(projectCode); + const initialTileSet = getAutoTileSet(); + const [activeTileSet, setActiveTileSet] = useState(initialTileSet); + const { tileSets = '' } = project?.config || {}; + const customTilesetNames = tileSets.split(','); + const customTileSets = customTilesetNames + .map( + tileset => + CUSTOM_TILE_SETS.find(({ key }) => key === tileset) as (typeof CUSTOM_TILE_SETS)[0], + ) + .filter(item => item); + const defaultTileSets = [DEFAULT_TILESETS.osm, DEFAULT_TILESETS.satellite]; + const availableTileSets = [...defaultTileSets, ...customTileSets]; + + useGAEffect('Map', 'Change Tile Set', activeTileSet?.label); + + const onTileSetChange = (tileSetKey: string) => { + const newActiveTileSet = availableTileSets.find( + ({ key }) => key === tileSetKey, + ) as (typeof CUSTOM_TILE_SETS)[0]; + setActiveTileSet(newActiveTileSet); + }; + + useEffect(() => { + if ( + activeTileSet && + availableTileSets.length > 0 && + !availableTileSets.some(({ key }) => key === activeTileSet.key) + ) { + setActiveTileSet(initialTileSet); + } + }, [JSON.stringify(availableTileSets)]); + + return { + availableTileSets, + activeTileSet, + onTileSetChange, + }; +}; diff --git a/packages/tupaia-web/src/utils/downloadPDF.ts b/packages/tupaia-web/src/utils/downloadPDF.ts new file mode 100644 index 0000000000..8d8d3c80d2 --- /dev/null +++ b/packages/tupaia-web/src/utils/downloadPDF.ts @@ -0,0 +1,5 @@ +import downloadJs from 'downloadjs'; + +export const downloadPDF = (data: Blob, exportFileName: string) => { + downloadJs(data, `${exportFileName}.pdf`); +}; diff --git a/packages/tupaia-web/src/utils/index.ts b/packages/tupaia-web/src/utils/index.ts index d9fbef417c..095192f6d4 100644 --- a/packages/tupaia-web/src/utils/index.ts +++ b/packages/tupaia-web/src/utils/index.ts @@ -7,7 +7,7 @@ export { getProjectAccessType } from './getProjectAccessType'; export { removeUrlSearchParams } from './removeUrlSearchParams'; export { useModal } from './useModal'; export { useEntityLink } from './useEntityLink'; -export { useDateRanges } from './useDateRanges'; +export { useDateRanges, convertDateRangeToUrlPeriodString } from './useDateRanges'; export { gaEvent } from './ga'; export { transformDownloadLink } from './transformDownloadLink'; export { useDebounce } from './useDebounce'; @@ -15,3 +15,4 @@ export { getDefaultDashboard } from './getDefaultDashboard'; export { useGAEffect } from './useGAEffect'; export { useUrlLoginToken } from './useUrlLoginToken'; export { getTopBarHeight, getMobileTopBarHeight } from './getTopBarHeight'; +export { downloadPDF } from './downloadPDF'; diff --git a/packages/tupaia-web/src/utils/useDateRanges.ts b/packages/tupaia-web/src/utils/useDateRanges.ts index 466806bdc8..f1f7f1c2e6 100644 --- a/packages/tupaia-web/src/utils/useDateRanges.ts +++ b/packages/tupaia-web/src/utils/useDateRanges.ts @@ -15,7 +15,7 @@ import { DEFAULT_PERIOD_PARAM_STRING } from '../constants'; type SelectableMapOverlay = TupaiaWebMapOverlaysRequest.TranslatedMapOverlay; // converts the date range to a URL period string -const convertDateRangeToUrlPeriodString = ( +export const convertDateRangeToUrlPeriodString = ( { startDate, endDate, diff --git a/packages/tupaia-web/src/views/PDFExport.tsx b/packages/tupaia-web/src/views/DashboardPDFExport.tsx similarity index 96% rename from packages/tupaia-web/src/views/PDFExport.tsx rename to packages/tupaia-web/src/views/DashboardPDFExport.tsx index e88266a1ca..92a761f6de 100644 --- a/packages/tupaia-web/src/views/PDFExport.tsx +++ b/packages/tupaia-web/src/views/DashboardPDFExport.tsx @@ -19,7 +19,7 @@ const Parent = styled.div<{ $isPreview?: boolean }>` ${({ $isPreview }) => ($isPreview ? `aspect-ratio: ${A4_RATIO};` : '')}; `; -interface PDFExportProps { +interface DashboardPDFExportProps { selectedDashboardItems?: TupaiaWebExportDashboardRequest.ReqBody['selectedDashboardItems']; isPreview?: boolean; } @@ -27,10 +27,10 @@ interface PDFExportProps { /** * This is the view that gets hit by puppeteer when generating a PDF. */ -export const PDFExport = ({ +export const DashboardPDFExport = ({ selectedDashboardItems: propsSelectedDashboardItems, isPreview = false, -}: PDFExportProps) => { +}: DashboardPDFExportProps) => { // Hacky way to change default background color without touching root css. Only apply when generating the pdf, not when in preview mode as it changes the display if (!isPreview) { document.body.style.backgroundColor = 'white'; diff --git a/packages/tupaia-web/src/views/MapOverlayPDFExport.tsx b/packages/tupaia-web/src/views/MapOverlayPDFExport.tsx new file mode 100644 index 0000000000..3e943c24a5 --- /dev/null +++ b/packages/tupaia-web/src/views/MapOverlayPDFExport.tsx @@ -0,0 +1,183 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import moment from 'moment'; +import styled from 'styled-components'; +import { useParams } from 'react-router'; +import { useSearchParams } from 'react-router-dom'; +import { Typography } from '@material-ui/core'; +import { LeafletMap, TileLayer, getAutoTileSet } from '@tupaia/ui-map-components'; +import { A4_PAGE_HEIGHT_PX, A4_PAGE_WIDTH_PX } from '@tupaia/ui-components'; +import { MapOverlaysLayer, Legend } from '../features/Map'; +import { useMapOverlays, useProject } from '../api/queries'; +import { DEFAULT_PERIOD_PARAM_STRING, URL_SEARCH_PARAMS } from '../constants'; +import { useDateRanges } from '../utils'; + +const Parent = styled.div` + // reverse the width and height to make the map landscape + height: ${A4_PAGE_WIDTH_PX - 2}px; + width: ${A4_PAGE_HEIGHT_PX}px; + position: relative; + overflow: hidden; +`; + +const MapContainer = styled.div` + height: 100%; + transition: width 0.5s ease; + width: 100%; + flex: 1; + position: relative; + display: flex; + flex-direction: column; + + .leaflet-container { + min-height: 15rem; + // This is to compensate for the pdf resolution scaling the map down to look smaller than what the screen was displaying. We cannot always do this via map zoom, because the map zoom is limited to the tile set zoom levels. + zoom: 1.5; + } +`; + +const StyledMap = styled(LeafletMap)` + height: 100%; + width: 100%; + flex: 1; + + .leaflet-pane { + // Set z-index of map pane to 0 so that it doesn't overlap with the sidebar and the map controls + z-index: 0; + } + + .leaflet-bottom { + // Set the z-index to 1 so it doesn't overlap dashboard controls on mobile + z-index: 1; + } +`; + +const LegendWrapper = styled.div` + position: absolute; + bottom: 2.8rem; + left: 2.8rem; +`; + +const MapOverlayInfoContainer = styled.div` + position: absolute; + top: 1.3rem; + left: 2.1rem; + background-color: white; + border: 1px solid ${({ theme }) => theme.palette.divider}; + max-width: 27rem; + min-width: 20rem; + padding-inline: 1.3rem; + padding-block: 1rem; + display: flex; + align-items: center; +`; + +const MapOverlayInfoText = styled(Typography)` + font-size: 0.875rem; + font-weight: 500; +`; + +const MapOverlayName = styled(MapOverlayInfoText).attrs({ + variant: 'h2', +})` + color: black; + font-size: 1rem; +`; + +const LatestDataText = styled(MapOverlayInfoText)` + color: ${({ theme }) => theme.palette.divider}; + margin-block-start: 0.4rem; +`; + +const LogoWrapper = styled.div` + width: 5rem; + height: 5rem; + border-right: 1px solid ${({ theme }) => theme.palette.divider}; + display: flex; + align-items: center; + justify-content: center; + padding-inline-end: 0.8rem; +`; +const ProjectLogo = styled.img` + max-width: 100%; + height: auto; +`; + +const TextWrapper = styled.div` + margin-inline-start: 0.8rem; + width: calc(100% - 5rem); +`; + +const useExportParams = () => { + const [urlSearchParams] = useSearchParams(); + + const initialTileSet = getAutoTileSet(); + const tileset = urlSearchParams.get('tileset') ?? initialTileSet.url; + const urlCenter = urlSearchParams.get('center'); + const urlZoom = urlSearchParams.get('zoom'); + const zoom = urlZoom ? parseInt(urlZoom) : 5; + const center = urlCenter ? JSON.parse(urlCenter) : undefined; + const urlHiddenValues = urlSearchParams.get('hiddenValues'); + const hiddenValues = urlHiddenValues ? JSON.parse(urlHiddenValues) : undefined; + const period = + urlSearchParams.get(URL_SEARCH_PARAMS.MAP_OVERLAY_PERIOD) ?? DEFAULT_PERIOD_PARAM_STRING; + + const locale = urlSearchParams.get('locale') ?? 'en-AU'; + + return { tileset, zoom, center, hiddenValues, period, locale }; +}; + +/** + * This is the view that gets hit by puppeteer when generating a map overlay PDF. + */ +export const MapOverlayPDFExport = () => { + const { projectCode, entityCode } = useParams(); + + const { data: project } = useProject(projectCode); + + const { selectedOverlay } = useMapOverlays(projectCode, entityCode); + const { tileset, zoom, center, hiddenValues, locale } = useExportParams(); + + const { startDate, endDate } = useDateRanges( + URL_SEARCH_PARAMS.MAP_OVERLAY_PERIOD, + selectedOverlay, + ); + + const getDateRangeString = () => { + if (startDate && endDate) { + const startDateString = moment(startDate).toDate().toLocaleDateString(locale); + const endDateString = moment(endDate).toDate().toLocaleDateString(locale); + return `${startDateString} - ${endDateString}`; + } + return ''; + }; + + const dateRangeString = getDateRangeString(); + + return ( + + + + + + + + + + + + + {selectedOverlay?.name} + {dateRangeString && Date of data: {dateRangeString}} + + + + {}} isExport /> + + + ); +}; diff --git a/packages/tupaia-web/src/views/index.ts b/packages/tupaia-web/src/views/index.ts index 72f790f818..c1b6549b68 100644 --- a/packages/tupaia-web/src/views/index.ts +++ b/packages/tupaia-web/src/views/index.ts @@ -12,5 +12,6 @@ export { RequestProjectAccessModal } from './RequestProjectAccessModal'; export { ForgotPasswordModal } from './ForgotPasswordModal'; export { EmailVerificationModal } from './EmailVerificationModal'; export { VerifyEmailResendModal } from './VerifyEmailResendModal'; -export { PDFExport } from './PDFExport'; +export { DashboardPDFExport } from './DashboardPDFExport'; export { Unsubscribe } from './Unsubscribe'; +export { MapOverlayPDFExport } from './MapOverlayPDFExport'; diff --git a/packages/types/generate-schemas.ts b/packages/types/generate-schemas.ts index 48766f46c4..97b0f9e06c 100644 --- a/packages/types/generate-schemas.ts +++ b/packages/types/generate-schemas.ts @@ -16,12 +16,10 @@ const settings: TJS.PartialArgs = { function getTsFiles(dir: string): string[] { const dirContents = fs.readdirSync(dir); - const files = dirContents - .map(dirItem => { - const res = resolve(dir, dirItem); - return fs.statSync(res).isDirectory() ? getTsFiles(res) : res; - }) - .flat(); + const files = dirContents.flatMap(dirItem => { + const res = resolve(dir, dirItem); + return fs.statSync(res).isDirectory() ? getTsFiles(res) : res; + }); return files.filter(fileName => fileName.endsWith('.ts')); } diff --git a/packages/types/src/types/requests/index.ts b/packages/types/src/types/requests/index.ts index 5b2fb86552..8a82756dbc 100644 --- a/packages/types/src/types/requests/index.ts +++ b/packages/types/src/types/requests/index.ts @@ -38,6 +38,7 @@ export { TupaiaWebSubscribeDashboardRequest, TupaiaWebUnsubscribeDashboardRequest, TupaiaWebUnsubscribeDashboardMailingListRequest, + TupaiaWebExportMapOverlayRequest, TupaiaWebExportSurveyDataRequest, } from './tupaia-web-server'; export { ProjectResponse, WebServerEntityRequest, WebServerProjectRequest } from './web-server'; diff --git a/packages/types/src/types/requests/tupaia-web-server/ExportMapOverlayRequest.ts b/packages/types/src/types/requests/tupaia-web-server/ExportMapOverlayRequest.ts new file mode 100644 index 0000000000..0f3bb23e8e --- /dev/null +++ b/packages/types/src/types/requests/tupaia-web-server/ExportMapOverlayRequest.ts @@ -0,0 +1,28 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { MapOverlay } from '../../models'; + +export interface Params { + projectCode: string; + entityCode: string; + mapOverlayCode: MapOverlay['code']; +} +export interface ResBody { + contents: Buffer; + filePath?: string; + type: string; +} +export type ReqBody = { + cookieDomain: string; + baseUrl: string; + center: string; + zoom: number; + tileset: string; + hiddenValues: string; + mapOverlayPeriod?: string; + locale?: string; +}; +export type ReqQuery = Record; diff --git a/packages/types/src/types/requests/tupaia-web-server/index.ts b/packages/types/src/types/requests/tupaia-web-server/index.ts index ec851bfcda..2cc345915d 100644 --- a/packages/types/src/types/requests/tupaia-web-server/index.ts +++ b/packages/types/src/types/requests/tupaia-web-server/index.ts @@ -20,4 +20,5 @@ export * as TupaiaWebUserRequest from './UserRequest'; export * as TupaiaWebSubscribeDashboardRequest from './SubscribeDashboardRequest'; export * as TupaiaWebUnsubscribeDashboardRequest from './UnsubscribeDashboardRequest'; export * as TupaiaWebUnsubscribeDashboardMailingListRequest from './UnsubscribeDashboardMailingListRequest'; +export * as TupaiaWebExportMapOverlayRequest from './ExportMapOverlayRequest'; export * as TupaiaWebExportSurveyDataRequest from './ExportSurveyDataRequest'; diff --git a/packages/ui-components/src/components/Inputs/Autocomplete.tsx b/packages/ui-components/src/components/Inputs/Autocomplete.tsx index 9bcc63869d..3850b23224 100644 --- a/packages/ui-components/src/components/Inputs/Autocomplete.tsx +++ b/packages/ui-components/src/components/Inputs/Autocomplete.tsx @@ -56,6 +56,7 @@ export interface BaseAutocompleteProps { onChange?: (event: Event, newValue: any) => void; getOptionSelected?: (option: any, value: any) => boolean; getOptionLabel?: (option: any) => string; + renderOption?: (option: any) => JSX.Element; placeholder?: string; muiProps?: any; } @@ -79,6 +80,7 @@ export const Autocomplete = ({ label = '', getOptionSelected, getOptionLabel, + renderOption, value, onChange, loading = false, @@ -111,6 +113,7 @@ export const Autocomplete = ({ inputValue={inputValue} getOptionSelected={getOptionSelected} getOptionLabel={getOptionLabel} + renderOption={renderOption} popupIcon={} PaperComponent={StyledPaper} renderInput={params => ( diff --git a/packages/ui-components/src/components/Modal/ImportModal.jsx b/packages/ui-components/src/components/Modal/ImportModal.jsx index ae97fa1f29..e390e56091 100644 --- a/packages/ui-components/src/components/Modal/ImportModal.jsx +++ b/packages/ui-components/src/components/Modal/ImportModal.jsx @@ -89,7 +89,7 @@ export const ImportModal = ({ {children} ) - : ({ children }) => <>{children}; + : React.Fragment; const renderContent = useCallback(() => { switch (status) { diff --git a/packages/ui-components/src/components/PDFExportComponent.tsx b/packages/ui-components/src/components/PDFExportComponent.tsx index 767133b5b7..b7f470a57a 100644 --- a/packages/ui-components/src/components/PDFExportComponent.tsx +++ b/packages/ui-components/src/components/PDFExportComponent.tsx @@ -14,7 +14,7 @@ export const A4_PAGE_HEIGHT_MM = 297; * use absolute length units (like cm and pt) and remove these constants at some point. */ export const A4_PAGE_WIDTH_PX = 1191; // at 144ppi -export const A4_PAGE_HEIGHT_PX = 1684; // at 144ppi +export const A4_PAGE_HEIGHT_PX = 1683; // at 144ppi export const A4Page = styled.div` width: ${A4_PAGE_WIDTH_PX}px; diff --git a/packages/ui-map-components/src/components/Legend/Legend.tsx b/packages/ui-map-components/src/components/Legend/Legend.tsx index 2fb2ecad43..1b9a99a217 100644 --- a/packages/ui-map-components/src/components/Legend/Legend.tsx +++ b/packages/ui-map-components/src/components/Legend/Legend.tsx @@ -14,6 +14,7 @@ type MeasureTypeLiteral = `${MeasureType}` | 'popup-only'; const LegendFrame = styled.div<{ isDisplayed: boolean; + $isExport?: boolean; }>` display: flex; width: fit-content; @@ -26,9 +27,12 @@ const LegendFrame = styled.div<{ opacity: ${props => (props.isDisplayed ? '100%' : '20%')}; margin: 0.6rem auto; - ${p => p.theme.breakpoints.down('sm')} { - margin: 0.6rem 0.6rem 0.6rem 0; - } + ${({ $isExport, theme }) => + !$isExport && + ` + ${theme.breakpoints.down('sm')} { + margin: 0.6rem 0.6rem 0.6rem 0; + }`} `; const LegendName = styled.div` @@ -50,7 +54,7 @@ interface LegendProps extends BaseLegendProps { displayedMapOverlayCodes?: string[]; seriesesKey?: string; SeriesContainer?: React.ComponentType; - SeriesDivider?: React.ComponentType; + SeriesDivider?: React.ComponentType | null; } export const Legend = React.memo( @@ -64,6 +68,7 @@ export const Legend = React.memo( seriesesKey = 'serieses', SeriesContainer = LegendFrame, SeriesDivider, + isExport, }: LegendProps) => { if (Object.keys(baseMeasureInfo).length === 0) { return null; @@ -124,7 +129,12 @@ export const Legend = React.memo( .sort(a => (a.type === MeasureType.COLOR ? -1 : 1)) // color series should sit at the top .map((series, index) => { return ( - + {legendsHaveSameType && {`${series.name}: `}} {index < serieses.length - 1 && SeriesDivider && } @@ -151,6 +162,7 @@ type LegendComponentProps = { hasRadiusLayer: boolean; hiddenValues: LegendProps['hiddenValues']; setValueHidden: LegendProps['setValueHidden']; + isExport?: LegendProps['isExport']; }; /** @@ -163,6 +175,7 @@ const LegendComponent = ({ hasRadiusLayer, hiddenValues, setValueHidden, + isExport, }: LegendComponentProps) => { const { type } = series; switch (type) { @@ -186,6 +199,7 @@ const LegendComponent = ({ series={series} setValueHidden={setValueHidden} hiddenValues={hiddenValues} + isExport={isExport} /> ); } diff --git a/packages/ui-map-components/src/components/Legend/MarkerLegend.tsx b/packages/ui-map-components/src/components/Legend/MarkerLegend.tsx index d96e4d1a63..be8fc7f895 100644 --- a/packages/ui-map-components/src/components/Legend/MarkerLegend.tsx +++ b/packages/ui-map-components/src/components/Legend/MarkerLegend.tsx @@ -4,7 +4,7 @@ */ import React from 'react'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { useTheme } from '@material-ui/core/styles'; import MuiBox from '@material-ui/core/Box'; import { IconKey } from '@tupaia/types'; @@ -27,16 +27,22 @@ import { } from '../../utils'; import { LegendEntry } from './LegendEntry'; -const Container = styled(MuiBox)` +const Container = styled(MuiBox)<{ + $isExport?: boolean; +}>` display: flex; align-items: center; justify-content: center; flex-wrap: wrap; - ${p => p.theme.breakpoints.down('sm')} { - flex-direction: column; - align-items: flex-start; - } + ${({ $isExport, theme }) => + !$isExport && + css` + ${theme.breakpoints.down('sm')} { + flex-direction: column; + align-items: flex-start; + } + `} `; /** @@ -117,6 +123,7 @@ export const MarkerLegend = React.memo( hasIconLayer, hasRadiusLayer, hasColorLayer, + isExport, }: MarkerLegendProps) => { const { type, values, key: dataKey, valueMapping } = series; const icon = 'icon' in series ? series.icon : null; @@ -181,7 +188,7 @@ export const MarkerLegend = React.memo( } return ( - + {keys} {nullKey} diff --git a/packages/ui-map-components/src/types/legend.ts b/packages/ui-map-components/src/types/legend.ts index f66285ea98..544bcea97d 100644 --- a/packages/ui-map-components/src/types/legend.ts +++ b/packages/ui-map-components/src/types/legend.ts @@ -8,6 +8,7 @@ import { MarkerSeries, SeriesValue, SpectrumSeries } from './series'; export type LegendProps = { setValueHidden: (dataKey: string, value: SeriesValue['value'], hidden: boolean) => void; hiddenValues: Record>; + isExport?: boolean; }; export type MarkerLegendProps = LegendProps & { diff --git a/yarn.lock b/yarn.lock index f055756bb3..ad60ef123d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12590,6 +12590,7 @@ __metadata: react: ^16.13.1 react-dom: ^16.13.1 react-hook-form: ^6.15.1 + react-leaflet: ^3.2.1 react-query: ^3.39.3 react-router: 6.3.0 react-router-dom: 6.3.0