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