diff --git a/i18n/en.pot b/i18n/en.pot index e6c85ac9..4d4d3975 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2022-04-29T09:28:31.324Z\n" -"PO-Revision-Date: 2022-04-29T09:28:31.324Z\n" +"POT-Creation-Date: 2022-09-08T10:43:07.553Z\n" +"PO-Revision-Date: 2022-09-08T10:43:07.553Z\n" msgid "Data values - Create/update" msgstr "" @@ -790,6 +790,9 @@ msgstr "" msgid "Create theme" msgstr "" +msgid "Access to Themes" +msgstr "" + msgid "Error deleting data values" msgstr "" @@ -996,7 +999,7 @@ msgstr "" msgid "data values" msgstr "" -msgid "Select import Organisation Unit" +msgid "Override import Organisation Unit" msgstr "" msgid "No capture org unit match element org units" diff --git a/i18n/es.po b/i18n/es.po index 2009f0ce..e8b0ef2c 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2022-04-29T09:28:31.324Z\n" +"POT-Creation-Date: 2022-09-08T10:43:07.553Z\n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -817,6 +817,10 @@ msgstr "Subtítulo" msgid "Create theme" msgstr "Crear tema" +#, fuzzy +msgid "Access to Themes" +msgstr "Acceso a plantillas" + msgid "Error deleting data values" msgstr "Se ha producido un error borrando valores de datos" @@ -1039,7 +1043,7 @@ msgstr "Actualizar" msgid "data values" msgstr "valores de datos" -msgid "Select import Organisation Unit" +msgid "Override import Organisation Unit" msgstr "Seleccione una unidad organizativa en la que importar los datos" msgid "No capture org unit match element org units" diff --git a/i18n/fr.po b/i18n/fr.po index a44bf038..c25da6cc 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load App\n" -"POT-Creation-Date: 2022-04-29T09:28:31.324Z\n" +"POT-Creation-Date: 2022-09-08T10:43:07.553Z\n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -839,6 +839,10 @@ msgstr "Sous-titre" msgid "Create theme" msgstr "Créer un thème" +#, fuzzy +msgid "Access to Themes" +msgstr "Accès à la génération de modèles" + msgid "Error deleting data values" msgstr "Erreur lors de la suppression des valeurs de données" @@ -1062,7 +1066,7 @@ msgstr "Mettre à jour" msgid "data values" msgstr "valeurs de données" -msgid "Select import Organisation Unit" +msgid "Override import Organisation Unit" msgstr "Sélectionnez l'unité d'organisation d'importation" msgid "No capture org unit match element org units" diff --git a/i18n/pt.po b/i18n/pt.po index e89ad9ad..3a8b646f 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2022-04-29T09:28:31.324Z\n" +"POT-Creation-Date: 2022-09-08T10:43:07.553Z\n" "Language: pt\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -872,6 +872,10 @@ msgstr "Legenda" msgid "Create theme" msgstr "Criar tema" +#, fuzzy +msgid "Access to Themes" +msgstr "Acesso à geração de planilhas" + msgid "Error deleting data values" msgstr "Erro ao excluir valores de dados" @@ -1096,7 +1100,7 @@ msgstr "Atualizar" msgid "data values" msgstr "valores de dados" -msgid "Select import Organisation Unit" +msgid "Override import Organisation Unit" msgstr "Selecione a unidade organizacional para importar os dados" msgid "No capture org unit match element org units" diff --git a/i18n/ru.po b/i18n/ru.po index f3421d81..3a16b162 100644 --- a/i18n/ru.po +++ b/i18n/ru.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2022-04-29T09:28:31.324Z\n" +"POT-Creation-Date: 2022-09-08T10:43:07.553Z\n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -876,6 +876,10 @@ msgstr "Субтитр" msgid "Create theme" msgstr "Создать тему" +#, fuzzy +msgid "Access to Themes" +msgstr "Доступ к генерации шаблонов" + msgid "Error deleting data values" msgstr "Ошибка при удалении значений данных" @@ -1100,7 +1104,7 @@ msgstr "Обновление" msgid "data values" msgstr "значения данных" -msgid "Select import Organisation Unit" +msgid "Override import Organisation Unit" msgstr "Выберите импортируемое организационное подразделение" msgid "No capture org unit match element org units" diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 57c18ee3..61d5f513 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -40,6 +40,7 @@ import { SaveThemeUseCase } from "./domain/usecases/SaveThemeUseCase"; import { SearchUsersUseCase } from "./domain/usecases/SearchUsersUseCase"; import { WriteSettingsUseCase } from "./domain/usecases/WriteSettingsUseCase"; import { D2Api } from "./types/d2-api"; +import { GetFilteredThemesUseCase } from "./domain/usecases/GetFilteredThemesUseCase"; export interface CompositionRootOptions { appConfig: JsonConfig; @@ -85,6 +86,7 @@ export function getCompositionRoot({ appConfig, dhisInstance, mockApi }: Composi list: new ListThemesUseCase(templateManager), save: new SaveThemeUseCase(templateManager), delete: new DeleteThemeUseCase(templateManager), + getFilteredThemes: new GetFilteredThemesUseCase(usersRepository), }), settings: getExecute({ getDefault: new GetDefaultSettingsUseCase(config), diff --git a/src/data/Dhis2RelationshipTypes.ts b/src/data/Dhis2RelationshipTypes.ts index ae22afdb..ed8440f6 100644 --- a/src/data/Dhis2RelationshipTypes.ts +++ b/src/data/Dhis2RelationshipTypes.ts @@ -253,7 +253,9 @@ async function getConstraintForTypeTei( fields: "trackedEntityInstance", } as const; - const results = await promiseMap(_.chunk(query.ou, 250), async ouChunk => { + const orgUnitsChunks = query.ou ? _.chunk(query.ou, 250) : [[]]; + + const results = await promiseMap(orgUnitsChunks, async ouChunk => { const filterQuery = query.ouMode === "SELECTED" || query.ouMode === "CHILDREN" || query.ouMode === "DESCENDANTS" ? { ...query, ou: ouChunk } diff --git a/src/data/Dhis2TrackedEntityInstances.ts b/src/data/Dhis2TrackedEntityInstances.ts index bb33b7df..cfcfda2d 100644 --- a/src/data/Dhis2TrackedEntityInstances.ts +++ b/src/data/Dhis2TrackedEntityInstances.ts @@ -1,5 +1,6 @@ import { PaginatedTeiGetResponse, + TeiGetRequest, TrackedEntityInstance as TrackedEntityInstanceApi, TrackedEntityInstanceGeometryAttributes, TrackedEntityInstanceToPost, @@ -175,7 +176,7 @@ async function runSequentialPromisesOnSuccess( // Private -/* A TEI cannot be posted if it includes relationships to other TEIs which are not created +/* A TEI cannot be posted if it includes relationships to other TEIs which are not created yet (creation of TEIS is sequential). So let's split pre/post TEI's so they can be posted separatedly. */ @@ -394,18 +395,34 @@ async function getExistingTeis(api: D2Api): Promise { fields: "trackedEntityInstance", } as const; - const { trackedEntityInstances: firstPage, pager } = await api.trackedEntityInstances.get(query).getData(); - const pages = _.range(2, pager.pageCount + 1); + // DHIS 2.37 added a new requirement: "Either Program or Tracked entity type should be specified" + // Requests to /api/trackedEntityInstances for these two params are singled-value, so we must + // perform multiple requests. Use Tracked Entity Types as tipically there will be more programs. - const otherPages = await promiseMap(pages, async page => { - const { trackedEntityInstances } = await api.trackedEntityInstances.get({ ...query, page }).getData(); - return trackedEntityInstances; + const metadata = await api.metadata.get({ trackedEntityTypes: { fields: { id: true } } }).getData(); + + const teisGroups = await promiseMap(metadata.trackedEntityTypes, async entityType => { + const queryWithEntityType: TeiGetRequest = { ...query, trackedEntityType: entityType.id }; + + const { trackedEntityInstances: firstPage, pager } = await api.trackedEntityInstances + .get(queryWithEntityType) + .getData(); + const pages = _.range(2, pager.pageCount + 1); + + const otherPages = await promiseMap(pages, async page => { + const { trackedEntityInstances } = await api.trackedEntityInstances + .get({ ...queryWithEntityType, page }) + .getData(); + return trackedEntityInstances; + }); + + return [...firstPage, ..._.flatten(otherPages)].map(({ trackedEntityInstance, ...rest }) => ({ + ...rest, + id: trackedEntityInstance, + })); }); - return [...firstPage, ..._.flatten(otherPages)].map(({ trackedEntityInstance, ...rest }) => ({ - ...rest, - id: trackedEntityInstance, - })); + return _.flatten(teisGroups); } type TeiKey = KeysOfUnion; diff --git a/src/data/ExcelPopulateRepository.ts b/src/data/ExcelPopulateRepository.ts index 42b00719..195372a7 100644 --- a/src/data/ExcelPopulateRepository.ts +++ b/src/data/ExcelPopulateRepository.ts @@ -467,7 +467,8 @@ function getFormulaWithValidation(workbook: XLSX.Workbook, sheet: SheetWithValid } function _getFormulaWithValidation(workbook: XLSX.Workbook, sheet: SheetWithValidations, cell: XLSX.Cell) { - const defaultValue = cell.formula(); + // Formulas some times return the = prefix, which the called does not expect. Force the removal. + const defaultValue = cell.formula()?.replace(/^=/, ""); const value = getValue(cell); if (defaultValue || !value) return defaultValue; diff --git a/src/domain/entities/Theme.ts b/src/domain/entities/Theme.ts index 8945c7b3..4ad9eec4 100644 --- a/src/domain/entities/Theme.ts +++ b/src/domain/entities/Theme.ts @@ -1,3 +1,4 @@ +import { SharingRule } from "@eyeseetea/d2-ui-components"; import { generateUid } from "d2/uid"; import _ from "lodash"; import { defaultColorScale } from "../../webapp/utils/colors"; @@ -9,6 +10,13 @@ export type Color = string; export type ThemeableSections = "title" | "subtitle"; export type ImageSections = "logo"; +export type Sharing = { + external: boolean; + public: string; + userGroups: SharingRule[]; + users: SharingRule[]; +}; + export interface ThemeStyle { text?: string; bold?: boolean; @@ -32,6 +40,13 @@ export interface CellImage { src: string; } +const defaultSharing: Sharing = { + external: false, + public: "r-------", + userGroups: [], + users: [], +}; + export class Theme { public readonly id: Id; public readonly name: string; @@ -43,6 +58,7 @@ export class Theme { public readonly pictures?: { [key in ImageSections]?: CellImage; }; + public readonly sharing: Sharing; constructor({ id = generateUid(), @@ -51,6 +67,7 @@ export class Theme { palette = defaultColorScale, sections = {}, pictures = {}, + sharing = defaultSharing, }: Partial = {}) { this.id = id; this.name = name; @@ -58,6 +75,7 @@ export class Theme { this.palette = palette; this.sections = sections; this.pictures = pictures; + this.sharing = sharing; } private update(partialUpdate: Partial): Theme { @@ -86,6 +104,10 @@ export class Theme { return this.update({ palette }); } + public updateSharing(sharing: Theme["sharing"]): Theme { + return this.update({ sharing }); + } + public validate(): Validation { return _.pickBy({ name: _.compact([ diff --git a/src/domain/helpers/ExcelReader.ts b/src/domain/helpers/ExcelReader.ts index ca1dc225..37e06d4f 100644 --- a/src/domain/helpers/ExcelReader.ts +++ b/src/domain/helpers/ExcelReader.ts @@ -116,6 +116,7 @@ export class ExcelReader { const values = await promiseMap(cells, async cell => { const value = cell ? await this.readCellValue(template, cell) : undefined; + const optionId = await this.excelRepository.readCell(template.id, cell, { formula: true }); if (!isDefined(value)) return undefined; const orgUnit = await this.readCellValue(template, dataSource.orgUnit, cell); @@ -152,6 +153,7 @@ export class ExcelReader { dataElement: this.formatValue(dataElement), category: category ? this.formatValue(category) : undefined, value: this.formatValue(value), + optionId: optionId ? removeCharacters(optionId) : undefined, }, ], }; @@ -163,6 +165,7 @@ export class ExcelReader { private async readByCell(template: Template, dataSource: CellDataSource): Promise { const cell = await this.excelRepository.findRelativeCell(template.id, dataSource.ref); const value = cell ? await this.readCellValue(template, cell) : undefined; + const optionId = await this.excelRepository.readCell(template.id, cell, { formula: true }); if (!isDefined(value)) return []; const orgUnit = await this.readCellValue(template, dataSource.orgUnit); @@ -193,6 +196,7 @@ export class ExcelReader { dataElement: String(dataElement), category: category ? String(category) : undefined, value: this.formatValue(value), + optionId: optionId ? removeCharacters(optionId) : undefined, }, ], }, diff --git a/src/domain/usecases/GetFilteredThemesUseCase.ts b/src/domain/usecases/GetFilteredThemesUseCase.ts new file mode 100644 index 00000000..848e9ec4 --- /dev/null +++ b/src/domain/usecases/GetFilteredThemesUseCase.ts @@ -0,0 +1,24 @@ +import { UseCase } from "../../CompositionRoot"; +import { Theme } from "../entities/Theme"; +import { UsersRepository } from "../repositories/UsersRepository"; + +export class GetFilteredThemesUseCase implements UseCase { + constructor(private usersRepository: UsersRepository) {} + + public async execute(themes: Theme[]): Promise { + const currentUser = await this.usersRepository.getCurrentUser(); + const { userGroups } = currentUser; + + const filteredThemes = themes.filter(theme => { + return ( + userGroups.some(uG => theme.sharing.userGroups.some(userGroup => userGroup.id === uG.id)) || + theme.sharing.users.some(user => user.id === currentUser.id) || + theme.sharing.public !== "--------" || + theme.sharing.external || + theme.sharing === undefined + ); + }); + + return filteredThemes; + } +} diff --git a/src/webapp/components/template-selector/TemplateSelector.tsx b/src/webapp/components/template-selector/TemplateSelector.tsx index 12706c05..48c968bb 100644 --- a/src/webapp/components/template-selector/TemplateSelector.tsx +++ b/src/webapp/components/template-selector/TemplateSelector.tsx @@ -29,14 +29,26 @@ export interface TemplateSelectorState extends DownloadTemplateProps { templateType?: TemplateType; } +export interface DataModelProps { + value: string; + label: string; +} + export interface TemplateSelectorProps { settings: Settings; themes: Theme[]; onChange(state: TemplateSelectorState | null): void; + onChangeModel(state: DataModelProps[]): void; customTemplates: CustomTemplate[]; } -export const TemplateSelector = ({ settings, themes, onChange, customTemplates }: TemplateSelectorProps) => { +export const TemplateSelector = ({ + settings, + themes, + onChange, + onChangeModel, + customTemplates, +}: TemplateSelectorProps) => { const classes = useStyles(); const { api, compositionRoot } = useAppContext(); @@ -99,6 +111,10 @@ export const TemplateSelector = ({ settings, themes, onChange, customTemplates } }); }, [models, compositionRoot, customTemplates, settings]); + useEffect(() => { + onChangeModel(templates); + }, [onChangeModel, templates]); + useEffect(() => { const { type, id } = state; if (type && id) { @@ -135,12 +151,9 @@ export const TemplateSelector = ({ settings, themes, onChange, customTemplates } const options = modelToSelectOption(dataSource[value] ?? []); setSelectedModel(value); - setState(state => ({ ...state, type: undefined, id: undefined, populate: false })); clearPopulateDates(); setTemplates(options); - setSelectedOrgUnits([]); - setOrgUnitTreeFilter([]); - setUserHasReadAccess(false); + onChangeModel(templates); }; const onTemplateChange = ({ value }: SelectOption) => { diff --git a/src/webapp/components/theme-list/ThemeListTable.tsx b/src/webapp/components/theme-list/ThemeListTable.tsx index e11eedce..ce674294 100644 --- a/src/webapp/components/theme-list/ThemeListTable.tsx +++ b/src/webapp/components/theme-list/ThemeListTable.tsx @@ -10,7 +10,7 @@ import { } from "@eyeseetea/d2-ui-components"; import { Button, Icon } from "@material-ui/core"; import _ from "lodash"; -import React, { ReactNode, useState } from "react"; +import React, { ReactNode, useEffect, useState } from "react"; import { Theme } from "../../../domain/entities/Theme"; import i18n from "../../../locales"; import { useAppContext } from "../../contexts/app-context"; @@ -18,6 +18,8 @@ import { RouteComponentProps } from "../../pages/Router"; import { promiseMap } from "../../../utils/promises"; import { ColorScale } from "../color-scale/ColorScale"; import ThemeEditDialog from "./ThemeEditDialog"; +import { ThemePermissionsDialog } from "./ThemePermissionsDialog"; +import { firstOrFail } from "../../../types/utils"; interface WarningDialog { title?: string; @@ -35,6 +37,7 @@ export interface ThemeDetail { } type ThemeListTableProps = Pick; +type SettingsState = { type: "closed" } | { type: "open"; id: string }; export default function ThemeListTable({ themes, setThemes }: ThemeListTableProps) { const { compositionRoot } = useAppContext(); @@ -45,7 +48,12 @@ export default function ThemeListTable({ themes, setThemes }: ThemeListTableProp const [themeEdit, setThemeEdit] = useState<{ type: "edit" | "new"; theme?: Theme }>(); const [warningDialog, setWarningDialog] = useState(null); - const rows = buildThemeDetails(themes); + const [theme, setTheme] = useState(); + useEffect(() => { + compositionRoot.themes.getFilteredThemes(themes).then(theme => setTheme(theme)); + }, [compositionRoot.themes, themes]); + + const rows = buildThemeDetails(theme ?? []); const newTheme = () => { setThemeEdit({ type: "new" }); @@ -108,6 +116,12 @@ export default function ThemeListTable({ themes, setThemes }: ThemeListTableProp ]; const actions: TableAction[] = [ + { + name: "sharing", + text: i18n.t("Sharing Settings"), + onClick: selectedIds => setSettingsState({ type: "open", id: firstOrFail(selectedIds) }), + icon: share, + }, { name: "edit", text: i18n.t("Edit"), @@ -127,6 +141,12 @@ export default function ThemeListTable({ themes, setThemes }: ThemeListTableProp setSelection(selection); }; + const [settingsState, setSettingsState] = useState({ type: "closed" }); + + const closeSettings = React.useCallback(() => { + setSettingsState({ type: "closed" }); + }, []); + return ( {!!warningDialog && ( @@ -152,6 +172,15 @@ export default function ThemeListTable({ themes, setThemes }: ThemeListTableProp /> )} + {settingsState.type === "open" && ( + + )} + rows={rows} columns={columns} diff --git a/src/webapp/components/theme-list/ThemePermissionsDialog.tsx b/src/webapp/components/theme-list/ThemePermissionsDialog.tsx new file mode 100644 index 00000000..d8323c68 --- /dev/null +++ b/src/webapp/components/theme-list/ThemePermissionsDialog.tsx @@ -0,0 +1,100 @@ +import { ConfirmationDialog, ShareUpdate, Sharing } from "@eyeseetea/d2-ui-components"; +import { useCallback } from "react"; +import i18n from "../../../locales"; +import { useAppContext } from "../../contexts/app-context"; +import { Id } from "../../../domain/entities/ReferenceObject"; +import { Theme } from "../../../domain/entities/Theme"; +import _ from "lodash"; + +export interface ThemePermissionsDialogProps { + themeId: Id; + onClose: () => void; + rows: Theme[]; + onChange: (rows: Theme[]) => void; +} + +export function ThemePermissionsDialog(props: ThemePermissionsDialogProps) { + const { themeId, onClose, rows: themes, onChange } = props; + const { compositionRoot } = useAppContext(); + + const search = useCallback((query: string) => compositionRoot.users.search(query), [compositionRoot]); + + const buildMetaObject = useCallback( + (id: Id) => { + const buildSharing = () => { + const theme = themes.find(theme => theme.id === id); + return { + users: theme?.sharing?.users, + userGroups: theme?.sharing?.userGroups, + external: theme?.sharing?.external, + public: theme?.sharing?.public, + }; + }; + + const sharing = buildSharing(); + + return { + meta: { + allowPublicAccess: true, + allowExternalAccess: false, + }, + object: { + id: "", + displayName: i18n.t("Access to Themes"), + externalAccess: sharing.external, + publicAccess: sharing.public, + userAccesses: sharing.users, + userGroupAccesses: sharing.userGroups, + }, + }; + }, + [themes] + ); + + const onUpdateSharingOptions = useCallback(() => { + return async ({ + userAccesses: users, + userGroupAccesses: userGroups, + publicAccess: publicSharing, + externalAccess: external, + }: ShareUpdate) => { + const newThemes = themes.map((theme): Theme => { + if (theme.id !== themeId) { + return theme; + } else { + const newTheme = theme.updateSharing({ + external: external ?? false, + public: publicSharing ?? "r-------", + users: users ?? [], + userGroups: userGroups ?? [], + }); + compositionRoot.themes + .save(newTheme) + .then(errors => + errors.length === 0 + ? onChange(_.uniqBy([theme, ...themes], "id")) + : console.error(errors.join("\n")) + ); + return newTheme; + } + }); + onChange(newThemes); + }; + }, [themes, onChange, themeId, compositionRoot.themes]); + + return ( + + + + ); +} diff --git a/src/webapp/pages/download-template/DownloadTemplatePage.tsx b/src/webapp/pages/download-template/DownloadTemplatePage.tsx index e8e7ef99..46375651 100644 --- a/src/webapp/pages/download-template/DownloadTemplatePage.tsx +++ b/src/webapp/pages/download-template/DownloadTemplatePage.tsx @@ -2,7 +2,11 @@ import { useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; import { Button, makeStyles } from "@material-ui/core"; import React, { useState } from "react"; import i18n from "../../../locales"; -import { TemplateSelector, TemplateSelectorState } from "../../components/template-selector/TemplateSelector"; +import { + DataModelProps, + TemplateSelector, + TemplateSelectorState, +} from "../../components/template-selector/TemplateSelector"; import { useAppContext } from "../../contexts/app-context"; import { RouteComponentProps } from "../Router"; @@ -13,9 +17,10 @@ export default function DownloadTemplatePage({ settings, themes, customTemplates const { api, compositionRoot } = useAppContext(); const [template, setTemplate] = useState(null); + const [availableModels, setAvailableModels] = useState([]); const handleTemplateDownloadClick = async () => { - if (!template) { + if (!template || availableModels.filter(availableModel => template?.id === availableModel.value).length === 0) { snackbar.info(i18n.t("You need to select at least one element to export")); return; } @@ -69,6 +74,7 @@ export default function DownloadTemplatePage({ settings, themes, customTemplates return ( } - label={i18n.t("Select import Organisation Unit")} + label={i18n.t("Override import Organisation Unit")} /> )}