From faed2e7431a8defe29ffb3ab218dab1b99267166 Mon Sep 17 00:00:00 2001 From: xurxodev Date: Thu, 1 Feb 2024 10:06:56 +0100 Subject: [PATCH 01/73] Create StorageType and replace hardcoded unions --- i18n/en.pot | 4 ++-- src/data/config/ConfigAppRepository.ts | 5 +++-- src/domain/config/entities/Config.ts | 1 + src/domain/config/repositories/ConfigRepository.ts | 3 ++- src/domain/config/usecases/GetStorageConfigUseCase.ts | 3 ++- src/domain/config/usecases/SetStorageConfigUseCase.ts | 3 ++- src/domain/storage/repositories/StorageClient.ts | 3 ++- .../webapp/core/pages/instance-list/InstanceListPage.tsx | 3 ++- .../core/pages/settings/storage/StorageSettingDropdown.tsx | 7 ++++--- 9 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 src/domain/config/entities/Config.ts diff --git a/i18n/en.pot b/i18n/en.pot index 4c9691291..48439ddf2 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: 2023-06-12T22:53:44.873Z\n" -"PO-Revision-Date: 2023-06-12T22:53:44.873Z\n" +"POT-Creation-Date: 2024-02-01T05:41:15.493Z\n" +"PO-Revision-Date: 2024-02-01T05:41:15.493Z\n" msgid "" "THIS NEW RELEASE INCLUDES SHARING SETTINGS PER INSTANCES. FOR THIS VERSION " diff --git a/src/data/config/ConfigAppRepository.ts b/src/data/config/ConfigAppRepository.ts index eba4efa81..0ea010333 100644 --- a/src/data/config/ConfigAppRepository.ts +++ b/src/data/config/ConfigAppRepository.ts @@ -1,4 +1,5 @@ import _ from "lodash"; +import { StorageType } from "../../domain/config/entities/Config"; import { ConfigRepository } from "../../domain/config/repositories/ConfigRepository"; import { Instance } from "../../domain/instance/entities/Instance"; import { StorageClient } from "../../domain/storage/repositories/StorageClient"; @@ -11,7 +12,7 @@ export class ConfigAppRepository implements ConfigRepository { constructor(private instance: Instance) {} @cache() - public async detectStorageClients(): Promise> { + public async detectStorageClients(): Promise> { const dataStoreClient = new StorageDataStoreClient(this.instance); const constantClient = new StorageConstantClient(this.instance); @@ -30,7 +31,7 @@ export class ConfigAppRepository implements ConfigRepository { return constantConfig ? constantClient : dataStoreClient; } - public async changeStorageClient(client: "dataStore" | "constant"): Promise { + public async changeStorageClient(client: StorageType): Promise { const dataStoreClient = new StorageDataStoreClient(this.instance); const constantClient = new StorageConstantClient(this.instance); diff --git a/src/domain/config/entities/Config.ts b/src/domain/config/entities/Config.ts new file mode 100644 index 000000000..e3d5aaff1 --- /dev/null +++ b/src/domain/config/entities/Config.ts @@ -0,0 +1 @@ +export type StorageType = "constant" | "dataStore"; diff --git a/src/domain/config/repositories/ConfigRepository.ts b/src/domain/config/repositories/ConfigRepository.ts index cb89f8e14..539db422a 100644 --- a/src/domain/config/repositories/ConfigRepository.ts +++ b/src/domain/config/repositories/ConfigRepository.ts @@ -1,5 +1,6 @@ import { Instance } from "../../instance/entities/Instance"; import { StorageClient } from "../../storage/repositories/StorageClient"; +import { StorageType } from "../entities/Config"; export interface ConfigRepositoryConstructor { new (instance: Instance): ConfigRepository; @@ -9,5 +10,5 @@ export interface ConfigRepository { // Storage client should only be accessible from data layer // This two methods will be removed in future refactors getStorageClient(): Promise; - changeStorageClient(client: "dataStore" | "constant"): Promise; + changeStorageClient(client: StorageType): Promise; } diff --git a/src/domain/config/usecases/GetStorageConfigUseCase.ts b/src/domain/config/usecases/GetStorageConfigUseCase.ts index 945e2c93b..740addd85 100644 --- a/src/domain/config/usecases/GetStorageConfigUseCase.ts +++ b/src/domain/config/usecases/GetStorageConfigUseCase.ts @@ -1,11 +1,12 @@ import { UseCase } from "../../common/entities/UseCase"; import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; import { Instance } from "../../instance/entities/Instance"; +import { StorageType } from "../entities/Config"; export class GetStorageConfigUseCase implements UseCase { constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} - public async execute(): Promise<"dataStore" | "constant"> { + public async execute(): Promise { const client = await this.repositoryFactory.configRepository(this.localInstance).getStorageClient(); return client.type; diff --git a/src/domain/config/usecases/SetStorageConfigUseCase.ts b/src/domain/config/usecases/SetStorageConfigUseCase.ts index c84e1ec56..2639c7ad9 100644 --- a/src/domain/config/usecases/SetStorageConfigUseCase.ts +++ b/src/domain/config/usecases/SetStorageConfigUseCase.ts @@ -1,11 +1,12 @@ import { UseCase } from "../../common/entities/UseCase"; import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; import { Instance } from "../../instance/entities/Instance"; +import { StorageType } from "../entities/Config"; export class SetStorageConfigUseCase implements UseCase { constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} - public async execute(client: "dataStore" | "constant"): Promise { + public async execute(client: StorageType): Promise { await this.repositoryFactory.configRepository(this.localInstance).changeStorageClient(client); } } diff --git a/src/domain/storage/repositories/StorageClient.ts b/src/domain/storage/repositories/StorageClient.ts index d51011231..431d09f8e 100644 --- a/src/domain/storage/repositories/StorageClient.ts +++ b/src/domain/storage/repositories/StorageClient.ts @@ -3,6 +3,7 @@ import { NamespaceProperties } from "../../../data/storage/Namespaces"; import { Dictionary } from "../../../types/utils"; import { Ref } from "../../common/entities/Ref"; import { SharingSetting } from "../../common/entities/SharingSetting"; +import { StorageType } from "../../config/entities/Config"; import { Instance } from "../../instance/entities/Instance"; export interface StorageClientConstructor { @@ -21,7 +22,7 @@ export interface ObjectSharing { } export abstract class StorageClient { - public abstract type: "constant" | "dataStore"; + public abstract type: StorageType; // Object operations public abstract getObject(key: string): Promise; diff --git a/src/presentation/webapp/core/pages/instance-list/InstanceListPage.tsx b/src/presentation/webapp/core/pages/instance-list/InstanceListPage.tsx index 0d0124b42..28ee8f8d4 100644 --- a/src/presentation/webapp/core/pages/instance-list/InstanceListPage.tsx +++ b/src/presentation/webapp/core/pages/instance-list/InstanceListPage.tsx @@ -28,6 +28,7 @@ import PageHeader from "../../../../react/core/components/page-header/PageHeader import { SharingDialog } from "../../../../react/core/components/sharing-dialog/SharingDialog"; import { TestWrapper } from "../../../../react/core/components/test-wrapper/TestWrapper"; import { useAppContext } from "../../../../react/core/contexts/AppContext"; +import { StorageType } from "../../../../../domain/config/entities/Config"; const InstanceListPage = () => { const { api, compositionRoot } = useAppContext(); @@ -42,7 +43,7 @@ const InstanceListPage = () => { const [toDelete, deleteInstances] = useState([]); const [sharingSettingsObject, setSharingSettingsObject] = useState(null); const [user, setUser] = useState(); - const [appStorage, setAppStorage] = useState<"dataStore" | "constant">(); + const [appStorage, setAppStorage] = useState(); const [localInstance, setLocalInstance] = useState(); useEffect(() => { diff --git a/src/presentation/webapp/core/pages/settings/storage/StorageSettingDropdown.tsx b/src/presentation/webapp/core/pages/settings/storage/StorageSettingDropdown.tsx index aee41e304..85a1ad609 100644 --- a/src/presentation/webapp/core/pages/settings/storage/StorageSettingDropdown.tsx +++ b/src/presentation/webapp/core/pages/settings/storage/StorageSettingDropdown.tsx @@ -1,5 +1,6 @@ import { ConfirmationDialog, ConfirmationDialogProps, useLoading } from "@eyeseetea/d2-ui-components"; import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { StorageType } from "../../../../../../domain/config/entities/Config"; import i18n from "../../../../../../locales"; import Dropdown from "../../../../../react/core/components/dropdown/Dropdown"; import { useAppContext } from "../../../../../react/core/contexts/AppContext"; @@ -20,7 +21,7 @@ export const StorageSettingDropdown: React.FC = () => { ); const changeStorage = useCallback( - async (storage: "constant" | "dataStore") => { + async (storage: StorageType) => { loading.show(true, i18n.t("Updating storage location, please wait...")); await compositionRoot.config.setStorage(storage); @@ -32,7 +33,7 @@ export const StorageSettingDropdown: React.FC = () => { ); const showConfirmationDialog = useCallback( - (storage: "constant" | "dataStore") => { + (storage: StorageType) => { updateDialog({ title: i18n.t("Change storage"), description: i18n.t( @@ -60,7 +61,7 @@ export const StorageSettingDropdown: React.FC = () => { {dialogProps && } - + items={options} value={selectedOption} onValueChange={showConfirmationDialog} From 34c2e1092a20a2cbfa9c67b95fdbb8aa374a099e Mon Sep 17 00:00:00 2001 From: xurxodev Date: Thu, 1 Feb 2024 10:36:50 +0100 Subject: [PATCH 02/73] Add accept and cancel buttons to the page: - Move logic to change storage from StorageSettingsDropdown to the page - The warning dialog is shown to click on accept button --- .../core/pages/settings/SettingsPage.tsx | 88 ++++++++++++++++++- .../storage/StorageSettingDropdown.tsx | 56 ++---------- 2 files changed, 91 insertions(+), 53 deletions(-) diff --git a/src/presentation/webapp/core/pages/settings/SettingsPage.tsx b/src/presentation/webapp/core/pages/settings/SettingsPage.tsx index 35965dddb..897e3c4ff 100644 --- a/src/presentation/webapp/core/pages/settings/SettingsPage.tsx +++ b/src/presentation/webapp/core/pages/settings/SettingsPage.tsx @@ -1,15 +1,78 @@ -import { FormGroup, makeStyles, Paper } from "@material-ui/core"; -import React from "react"; +import { ConfirmationDialog, ConfirmationDialogProps, useLoading } from "@eyeseetea/d2-ui-components"; +import { Button, FormGroup, makeStyles, Paper } from "@material-ui/core"; +import React, { useCallback, useEffect, useState } from "react"; import { useHistory } from "react-router-dom"; +import styled from "styled-components"; +import { StorageType } from "../../../../../domain/config/entities/Config"; import i18n from "../../../../../locales"; import PageHeader from "../../../../react/core/components/page-header/PageHeader"; +import { useAppContext } from "../../../../react/core/contexts/AppContext"; import { StorageSettingDropdown } from "./storage/StorageSettingDropdown"; export const SettingsPage: React.FC = () => { + const { compositionRoot } = useAppContext(); + const history = useHistory(); const classes = useStyles(); - const backHome = () => history.push("/dashboard"); + const [storageType, setStorageType] = useState("dataStore"); + const [savedStorageType, setSavedStorageType] = useState("dataStore"); + + const loading = useLoading(); + + const [dialogProps, updateDialog] = useState(null); + + useEffect(() => { + compositionRoot.config.getStorage().then(storage => { + setStorageType(storage); + setSavedStorageType(storage); + }); + }, [compositionRoot]); + + const backHome = useCallback(() => history.push("/dashboard"), [history]); + + const changeStorage = useCallback( + async (storage: StorageType) => { + loading.show(true, i18n.t("Updating storage location, please wait...")); + await compositionRoot.config.setStorage(storage); + + const newStorage = await compositionRoot.config.getStorage(); + setStorageType(newStorage); + loading.reset(); + backHome(); + }, + [backHome, compositionRoot.config, loading] + ); + + const showConfirmationDialog = useCallback(() => { + updateDialog({ + title: i18n.t("Change storage"), + description: i18n.t( + "When changing the storage of the application, all stored information will be moved to the new storage. This might take a while, please wait. Do you want to proceed?" + ), + onCancel: () => { + updateDialog(null); + }, + onSave: async () => { + updateDialog(null); + changeStorage(storageType); + }, + cancelText: i18n.t("Cancel"), + saveText: i18n.t("Proceed"), + }); + }, [changeStorage, storageType]); + + const onChangeStorageType = useCallback((storage: StorageType) => setStorageType(storage), []); + + const onSave = useCallback(() => { + if (storageType !== savedStorageType) { + showConfirmationDialog(); + } else { + backHome(); + } + }, [backHome, savedStorageType, showConfirmationDialog, storageType]); + + const onCancel = useCallback(() => backHome(), [backHome]); return ( @@ -19,9 +82,20 @@ export const SettingsPage: React.FC = () => {

{i18n.t("Application storage")}

- + + + + + + + + {dialogProps && }
); }; @@ -31,3 +105,9 @@ const useStyles = makeStyles({ title: { marginTop: 0 }, container: { margin: "1rem", padding: "1rem" }, }); + +const ButtonsContainer = styled.div` + display: flex; + flex-direction: row; + justify-content: end; +`; diff --git a/src/presentation/webapp/core/pages/settings/storage/StorageSettingDropdown.tsx b/src/presentation/webapp/core/pages/settings/storage/StorageSettingDropdown.tsx index 85a1ad609..aebdacc0b 100644 --- a/src/presentation/webapp/core/pages/settings/storage/StorageSettingDropdown.tsx +++ b/src/presentation/webapp/core/pages/settings/storage/StorageSettingDropdown.tsx @@ -1,17 +1,14 @@ -import { ConfirmationDialog, ConfirmationDialogProps, useLoading } from "@eyeseetea/d2-ui-components"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { StorageType } from "../../../../../../domain/config/entities/Config"; import i18n from "../../../../../../locales"; import Dropdown from "../../../../../react/core/components/dropdown/Dropdown"; -import { useAppContext } from "../../../../../react/core/contexts/AppContext"; -export const StorageSettingDropdown: React.FC = () => { - const { compositionRoot } = useAppContext(); - const loading = useLoading(); - - const [selectedOption, setSelectedOption] = useState("dataStore"); - const [dialogProps, updateDialog] = useState(null); +interface StorageSettingDropdownProps { + selectedOption: StorageType; + onChangeStorage: (storage: StorageType) => void; +} +export const StorageSettingDropdown: React.FC = ({ selectedOption, onChangeStorage }) => { const options = useMemo( () => [ { id: "dataStore" as const, name: i18n.t("Data Store") }, @@ -20,51 +17,12 @@ export const StorageSettingDropdown: React.FC = () => { [] ); - const changeStorage = useCallback( - async (storage: StorageType) => { - loading.show(true, i18n.t("Updating storage location, please wait...")); - await compositionRoot.config.setStorage(storage); - - const newStorage = await compositionRoot.config.getStorage(); - setSelectedOption(newStorage); - loading.reset(); - }, - [compositionRoot, loading] - ); - - const showConfirmationDialog = useCallback( - (storage: StorageType) => { - updateDialog({ - title: i18n.t("Change storage"), - description: i18n.t( - "When changing the storage of the application, all stored information will be moved to the new storage. This might take a while, please wait. Do you want to proceed?" - ), - onCancel: () => { - updateDialog(null); - }, - onSave: async () => { - updateDialog(null); - await changeStorage(storage); - }, - cancelText: i18n.t("Cancel"), - saveText: i18n.t("Proceed"), - }); - }, - [changeStorage] - ); - - useEffect(() => { - compositionRoot.config.getStorage().then(storage => setSelectedOption(storage)); - }, [compositionRoot]); - return ( - {dialogProps && } - items={options} value={selectedOption} - onValueChange={showConfirmationDialog} + onValueChange={onChangeStorage} hideEmpty={true} view={"full-width"} /> From 19a304eabf56770cc4e81f0091a6f393a1c02065 Mon Sep 17 00:00:00 2001 From: xurxodev Date: Thu, 1 Feb 2024 11:13:13 +0100 Subject: [PATCH 03/73] Extract presentation logic in SettingsPage to a custom hook --- .../core/pages/settings/SettingsPage.tsx | 68 ++++--------------- .../webapp/core/pages/settings/useSettings.ts | 67 ++++++++++++++++++ 2 files changed, 80 insertions(+), 55 deletions(-) create mode 100644 src/presentation/webapp/core/pages/settings/useSettings.ts diff --git a/src/presentation/webapp/core/pages/settings/SettingsPage.tsx b/src/presentation/webapp/core/pages/settings/SettingsPage.tsx index 897e3c4ff..ab8edd349 100644 --- a/src/presentation/webapp/core/pages/settings/SettingsPage.tsx +++ b/src/presentation/webapp/core/pages/settings/SettingsPage.tsx @@ -1,78 +1,36 @@ -import { ConfirmationDialog, ConfirmationDialogProps, useLoading } from "@eyeseetea/d2-ui-components"; +import { ConfirmationDialog, useLoading } from "@eyeseetea/d2-ui-components"; import { Button, FormGroup, makeStyles, Paper } from "@material-ui/core"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect } from "react"; import { useHistory } from "react-router-dom"; import styled from "styled-components"; -import { StorageType } from "../../../../../domain/config/entities/Config"; import i18n from "../../../../../locales"; import PageHeader from "../../../../react/core/components/page-header/PageHeader"; -import { useAppContext } from "../../../../react/core/contexts/AppContext"; import { StorageSettingDropdown } from "./storage/StorageSettingDropdown"; +import { useSettings } from "./useSettings"; export const SettingsPage: React.FC = () => { - const { compositionRoot } = useAppContext(); - const history = useHistory(); const classes = useStyles(); - const [storageType, setStorageType] = useState("dataStore"); - const [savedStorageType, setSavedStorageType] = useState("dataStore"); - const loading = useLoading(); - const [dialogProps, updateDialog] = useState(null); - - useEffect(() => { - compositionRoot.config.getStorage().then(storage => { - setStorageType(storage); - setSavedStorageType(storage); - }); - }, [compositionRoot]); + const { storageType, onChangeStorageType, onCancel, onSave, dialogProps, loadingMessage, goHome } = useSettings(); const backHome = useCallback(() => history.push("/dashboard"), [history]); - const changeStorage = useCallback( - async (storage: StorageType) => { - loading.show(true, i18n.t("Updating storage location, please wait...")); - await compositionRoot.config.setStorage(storage); - - const newStorage = await compositionRoot.config.getStorage(); - setStorageType(newStorage); + useEffect(() => { + if (loadingMessage) { + loading.show(true, loadingMessage); + } else { loading.reset(); - backHome(); - }, - [backHome, compositionRoot.config, loading] - ); - - const showConfirmationDialog = useCallback(() => { - updateDialog({ - title: i18n.t("Change storage"), - description: i18n.t( - "When changing the storage of the application, all stored information will be moved to the new storage. This might take a while, please wait. Do you want to proceed?" - ), - onCancel: () => { - updateDialog(null); - }, - onSave: async () => { - updateDialog(null); - changeStorage(storageType); - }, - cancelText: i18n.t("Cancel"), - saveText: i18n.t("Proceed"), - }); - }, [changeStorage, storageType]); - - const onChangeStorageType = useCallback((storage: StorageType) => setStorageType(storage), []); + } + }, [loading, loadingMessage]); - const onSave = useCallback(() => { - if (storageType !== savedStorageType) { - showConfirmationDialog(); - } else { + useEffect(() => { + if (goHome) { backHome(); } - }, [backHome, savedStorageType, showConfirmationDialog, storageType]); - - const onCancel = useCallback(() => backHome(), [backHome]); + }, [backHome, goHome]); return ( diff --git a/src/presentation/webapp/core/pages/settings/useSettings.ts b/src/presentation/webapp/core/pages/settings/useSettings.ts new file mode 100644 index 000000000..731632bba --- /dev/null +++ b/src/presentation/webapp/core/pages/settings/useSettings.ts @@ -0,0 +1,67 @@ +import { ConfirmationDialogProps } from "@eyeseetea/d2-ui-components"; +import { useCallback, useEffect, useState } from "react"; +import { StorageType } from "../../../../../domain/config/entities/Config"; +import i18n from "../../../../../locales"; +import { useAppContext } from "../../../../react/core/contexts/AppContext"; + +export function useSettings() { + const { compositionRoot } = useAppContext(); + + const [storageType, setStorageType] = useState("dataStore"); + const [savedStorageType, setSavedStorageType] = useState("dataStore"); + const [dialogProps, updateDialog] = useState(null); + const [loadingMessage, setLoadingMessage] = useState(); + const [goHome, setGoHome] = useState(false); + + useEffect(() => { + compositionRoot.config.getStorage().then(storage => { + setStorageType(storage); + setSavedStorageType(storage); + }); + }, [compositionRoot]); + + const changeStorage = useCallback( + async (storage: StorageType) => { + setLoadingMessage(i18n.t("Updating storage location, please wait...")); + await compositionRoot.config.setStorage(storage); + + const newStorage = await compositionRoot.config.getStorage(); + setStorageType(newStorage); + setLoadingMessage(undefined); + setGoHome(true); + }, + [compositionRoot.config] + ); + + const showConfirmationDialog = useCallback(() => { + updateDialog({ + title: i18n.t("Change storage"), + description: i18n.t( + "When changing the storage of the application, all stored information will be moved to the new storage. This might take a while, please wait. Do you want to proceed?" + ), + onCancel: () => { + updateDialog(null); + }, + onSave: async () => { + updateDialog(null); + changeStorage(storageType); + }, + cancelText: i18n.t("Cancel"), + saveText: i18n.t("Proceed"), + }); + }, [changeStorage, storageType]); + + const onChangeStorageType = useCallback((storage: StorageType) => setStorageType(storage), []); + + const onSave = useCallback(() => { + if (storageType !== savedStorageType) { + showConfirmationDialog(); + } else { + setGoHome(true); + } + }, [savedStorageType, showConfirmationDialog, storageType]); + + const onCancel = useCallback(() => setGoHome(true), []); + + return { storageType, onChangeStorageType, onCancel, onSave, dialogProps, loadingMessage, goHome }; +} From f1414614ed80be5f56440cd366a8f12e5fc5841e Mon Sep 17 00:00:00 2001 From: xurxodev Date: Mon, 19 Feb 2024 12:40:02 +0100 Subject: [PATCH 04/73] Read and save historyRetentionDays from the data store --- i18n/en.pot | 13 ++- i18n/es.po | 11 ++- i18n/fr.po | 11 ++- i18n/pt.po | 11 ++- src/data/settings/SettingsD2ApiRepository.ts | 33 +++++++ src/data/storage/Namespaces.ts | 1 + src/domain/common/entities/Either.ts | 9 ++ src/domain/common/entities/Validations.ts | 5 ++ .../common/factories/RepositoryFactory.ts | 8 ++ src/domain/settings/GetSettingsUseCase.ts | 10 +++ src/domain/settings/SaveSettingsUseCase.ts | 10 +++ src/domain/settings/Settings.ts | 41 +++++++++ src/domain/settings/SettingsRepository.ts | 11 +++ src/presentation/CompositionRoot.ts | 12 +++ .../core/pages/settings/SettingsPage.tsx | 44 +++++++++- .../webapp/core/pages/settings/useSettings.ts | 88 +++++++++++++++++-- 16 files changed, 304 insertions(+), 14 deletions(-) create mode 100644 src/data/settings/SettingsD2ApiRepository.ts create mode 100644 src/domain/settings/GetSettingsUseCase.ts create mode 100644 src/domain/settings/SaveSettingsUseCase.ts create mode 100644 src/domain/settings/Settings.ts create mode 100644 src/domain/settings/SettingsRepository.ts diff --git a/i18n/en.pot b/i18n/en.pot index 48439ddf2..1aa54cd7c 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: 2024-02-01T05:41:15.493Z\n" -"PO-Revision-Date: 2024-02-01T05:41:15.493Z\n" +"POT-Creation-Date: 2024-02-19T09:05:53.643Z\n" +"PO-Revision-Date: 2024-02-19T09:05:53.643Z\n" msgid "" "THIS NEW RELEASE INCLUDES SHARING SETTINGS PER INSTANCES. FOR THIS VERSION " @@ -26,6 +26,9 @@ msgstr "" msgid "Field {{field}} is not valid" msgstr "" +msgid "Field {{field}} is not a number" +msgstr "" + msgid "Program not found" msgstr "" @@ -1927,6 +1930,12 @@ msgstr "" msgid "Application storage" msgstr "" +msgid "Retention days" +msgstr "" + +msgid "Leave empty to keep all history" +msgstr "" + msgid "Data Store" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 3f94fcdb6..9282d9ff1 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2023-06-12T22:53:44.873Z\n" +"POT-Creation-Date: 2024-02-19T09:05:53.643Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -26,6 +26,9 @@ msgstr "" msgid "Field {{field}} is not valid" msgstr "" +msgid "Field {{field}} is not a number" +msgstr "" + msgid "Program not found" msgstr "" @@ -1930,6 +1933,12 @@ msgstr "" msgid "Application storage" msgstr "" +msgid "Retention days" +msgstr "" + +msgid "Leave empty to keep all history" +msgstr "" + msgid "Data Store" msgstr "" diff --git a/i18n/fr.po b/i18n/fr.po index ec45f848f..8d43bcfa5 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2023-06-12T22:53:44.873Z\n" +"POT-Creation-Date: 2024-02-19T09:05:53.643Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -26,6 +26,9 @@ msgstr "" msgid "Field {{field}} is not valid" msgstr "" +msgid "Field {{field}} is not a number" +msgstr "" + msgid "Program not found" msgstr "" @@ -1929,6 +1932,12 @@ msgstr "" msgid "Application storage" msgstr "" +msgid "Retention days" +msgstr "" + +msgid "Leave empty to keep all history" +msgstr "" + msgid "Data Store" msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index ec45f848f..8d43bcfa5 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2023-06-12T22:53:44.873Z\n" +"POT-Creation-Date: 2024-02-19T09:05:53.643Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -26,6 +26,9 @@ msgstr "" msgid "Field {{field}} is not valid" msgstr "" +msgid "Field {{field}} is not a number" +msgstr "" + msgid "Program not found" msgstr "" @@ -1929,6 +1932,12 @@ msgstr "" msgid "Application storage" msgstr "" +msgid "Retention days" +msgstr "" + +msgid "Leave empty to keep all history" +msgstr "" + msgid "Data Store" msgstr "" diff --git a/src/data/settings/SettingsD2ApiRepository.ts b/src/data/settings/SettingsD2ApiRepository.ts new file mode 100644 index 000000000..302931947 --- /dev/null +++ b/src/data/settings/SettingsD2ApiRepository.ts @@ -0,0 +1,33 @@ +import { ConfigRepository } from "../../domain/config/repositories/ConfigRepository"; +import { Settings, SettingsData } from "../../domain/settings/Settings"; +import { SettingsRepository } from "../../domain/settings/SettingsRepository"; +import { StorageClient } from "../../domain/storage/repositories/StorageClient"; +import { Namespace } from "../storage/Namespaces"; + +export class SettingsD2ApiRepository implements SettingsRepository { + constructor(private configRepository: ConfigRepository) {} + + async get(): Promise { + const storageClient = await this.getStorageClient(); + const settingsData = await storageClient.getObject(Namespace.SETTINGS); + + debugger; + + return settingsData + ? Settings.create({ historyRetentionDays: settingsData.historyRetentionDays?.toString() }).getOrThrow() + : Settings.create({ historyRetentionDays: undefined }).getOrThrow(); + } + + async save(settings: Settings): Promise { + const data = { + historyRetentionDays: settings.historyRetentionDays, + }; + + const storageClient = await this.getStorageClient(); + await storageClient.saveObject(Namespace.SETTINGS, data); + } + + private getStorageClient(): Promise { + return this.configRepository.getStorageClient(); + } +} diff --git a/src/data/storage/Namespaces.ts b/src/data/storage/Namespaces.ts index eb047182c..aeadd6ed4 100644 --- a/src/data/storage/Namespaces.ts +++ b/src/data/storage/Namespaces.ts @@ -10,6 +10,7 @@ export const Namespace = { STORES: "stores", RESPONSIBLES: "responsibles", MAPPINGS: "mappings", + SETTINGS: "settings", SCHEDULER_EXECUTIONS: "scheduler-executions", }; diff --git a/src/domain/common/entities/Either.ts b/src/domain/common/entities/Either.ts index cd1ede3ed..1910b07c8 100644 --- a/src/domain/common/entities/Either.ts +++ b/src/domain/common/entities/Either.ts @@ -49,6 +49,15 @@ export class Either { }); } + getOrThrow(): Data { + return this.match({ + success: () => (this.value as EitherValueSuccess).data, + error: () => { + throw Error("Return Either value is not possible because is left"); + }, + }); + } + static error(error: Error) { return new Either({ type: "error", error }); } diff --git a/src/domain/common/entities/Validations.ts b/src/domain/common/entities/Validations.ts index 495d50d9f..2d0ecc22b 100644 --- a/src/domain/common/entities/Validations.ts +++ b/src/domain/common/entities/Validations.ts @@ -44,6 +44,11 @@ const availableValidations = { getDescription: (field: string) => i18n.t("Field {{field}} is not valid", { field }), check: (value?: Ref) => !value?.id, }, + isNumeric: { + error: "invalid_number", + getDescription: (field: string) => i18n.t("Field {{field}} is not a number", { field }), + check: (value?: unknown) => Number.isNaN(Number(value)), + }, }; export function validateModel(item: T, validations: ModelValidation[]): ValidationError[] { diff --git a/src/domain/common/factories/RepositoryFactory.ts b/src/domain/common/factories/RepositoryFactory.ts index 9ece6d2a9..d94179c01 100644 --- a/src/domain/common/factories/RepositoryFactory.ts +++ b/src/domain/common/factories/RepositoryFactory.ts @@ -19,6 +19,7 @@ import { ReportsRepositoryConstructor } from "../../reports/repositories/Reports import { FileRulesRepositoryConstructor } from "../../rules/repositories/FileRulesRepository"; import { RulesRepositoryConstructor } from "../../rules/repositories/RulesRepository"; import { SchedulerRepositoryConstructor } from "../../scheduler/repositories/SchedulerRepository"; +import { SettingsRepositoryConstructor } from "../../settings/SettingsRepository"; import { DownloadRepositoryConstructor } from "../../storage/repositories/DownloadRepository"; import { StoreRepositoryConstructor } from "../../stores/repositories/StoreRepository"; import { TEIRepository, TEIRepositoryConstructor } from "../../tracked-entity-instances/repositories/TEIRepository"; @@ -175,6 +176,12 @@ export class RepositoryFactory { const config = this.configRepository(instance); return this.get(Repositories.SchedulerRepository, [config]); } + + @cache() + public settingsRepository(instance: Instance) { + const config = this.configRepository(instance); + return this.get(Repositories.SettingsRepository, [config]); + } } type RepositoryKeys = typeof Repositories[keyof typeof Repositories]; @@ -200,5 +207,6 @@ export const Repositories = { TEIsRepository: "teisRepository", UserRepository: "userRepository", MappingRepository: "mappingRepository", + SettingsRepository: "settingsRepository", SchedulerRepository: "schedulerRepository", } as const; diff --git a/src/domain/settings/GetSettingsUseCase.ts b/src/domain/settings/GetSettingsUseCase.ts new file mode 100644 index 000000000..7253e3c2d --- /dev/null +++ b/src/domain/settings/GetSettingsUseCase.ts @@ -0,0 +1,10 @@ +import { Settings } from "./Settings"; +import { SettingsRepository } from "./SettingsRepository"; + +export class GetSettingsUseCase { + constructor(private settingsRepository: SettingsRepository) {} + + async execute(): Promise { + return this.settingsRepository.get(); + } +} diff --git a/src/domain/settings/SaveSettingsUseCase.ts b/src/domain/settings/SaveSettingsUseCase.ts new file mode 100644 index 000000000..7e475a760 --- /dev/null +++ b/src/domain/settings/SaveSettingsUseCase.ts @@ -0,0 +1,10 @@ +import { Settings } from "./Settings"; +import { SettingsRepository } from "./SettingsRepository"; + +export class SaveSettingsUseCase { + constructor(private settingsRepository: SettingsRepository) {} + + async execute(settings: Settings): Promise { + return this.settingsRepository.save(settings); + } +} diff --git a/src/domain/settings/Settings.ts b/src/domain/settings/Settings.ts new file mode 100644 index 000000000..7a67cac60 --- /dev/null +++ b/src/domain/settings/Settings.ts @@ -0,0 +1,41 @@ +import { Either } from "../common/entities/Either"; +import { ModelValidation, validateModel, ValidationError } from "../common/entities/Validations"; + +export interface SettingsData { + historyRetentionDays: number | undefined; +} + +export interface SettingsParams { + historyRetentionDays: string | undefined; +} + +export class Settings { + public readonly historyRetentionDays: number | undefined; + + private constructor(data: SettingsData) { + this.historyRetentionDays = data.historyRetentionDays; + } + + static create(data: SettingsParams): Either { + const validations: ModelValidation[] = data.historyRetentionDays + ? [ + { + property: "historyRetentionDays", + validation: "isNumeric", + alias: "Retention days", + }, + ] + : []; + + const errors = validateModel(data, validations); + + if (errors.length > 0) { + return Either.error(errors); + } else { + const settings = new Settings({ + historyRetentionDays: data.historyRetentionDays ? Number(data.historyRetentionDays) : undefined, + }); + return Either.success(settings); + } + } +} diff --git a/src/domain/settings/SettingsRepository.ts b/src/domain/settings/SettingsRepository.ts new file mode 100644 index 000000000..35f29c104 --- /dev/null +++ b/src/domain/settings/SettingsRepository.ts @@ -0,0 +1,11 @@ +import { ConfigRepository } from "../config/repositories/ConfigRepository"; +import { Settings } from "./Settings"; + +export interface SettingsRepositoryConstructor { + new (configRepository: ConfigRepository): SettingsRepository; +} + +export interface SettingsRepository { + get(): Promise; + save(settings: Settings): Promise; +} diff --git a/src/presentation/CompositionRoot.ts b/src/presentation/CompositionRoot.ts index 3910eb3e2..d74f4ea25 100644 --- a/src/presentation/CompositionRoot.ts +++ b/src/presentation/CompositionRoot.ts @@ -14,6 +14,7 @@ import { ReportsD2ApiRepository } from "../data/reports/ReportsD2ApiRepository"; import { FileRulesDefaultRepository } from "../data/rules/FileRulesDefaultRepository"; import { RulesD2ApiRepository } from "../data/rules/RulesD2ApiRepository"; import { SchedulerD2ApiRepository } from "../data/scheduler/SchedulerD2ApiRepository"; +import { SettingsD2ApiRepository } from "../data/settings/SettingsD2ApiRepository"; import { DownloadWebRepository } from "../data/storage/DownloadWebRepository"; import { StoreD2ApiRepository } from "../data/stores/StoreD2ApiRepository"; import { SystemInfoD2ApiRepository } from "../data/system-info/SystemInfoD2ApiRepository"; @@ -100,6 +101,8 @@ import { ReadSyncRuleFilesUseCase } from "../domain/rules/usecases/ReadSyncRuleF import { SaveSyncRuleUseCase } from "../domain/rules/usecases/SaveSyncRuleUseCase"; import { GetLastSchedulerExecutionUseCase } from "../domain/scheduler/usecases/GetLastSchedulerExecutionUseCase"; import { UpdateLastSchedulerExecutionUseCase } from "../domain/scheduler/usecases/UpdateLastSchedulerExecutionUseCase"; +import { GetSettingsUseCase } from "../domain/settings/GetSettingsUseCase"; +import { SaveSettingsUseCase } from "../domain/settings/SaveSettingsUseCase"; import { DownloadFileUseCase } from "../domain/storage/usecases/DownloadFileUseCase"; import { DeleteStoreUseCase } from "../domain/stores/usecases/DeleteStoreUseCase"; import { GetStoreUseCase } from "../domain/stores/usecases/GetStoreUseCase"; @@ -143,6 +146,7 @@ export class CompositionRoot { this.repositoryFactory.bind(Repositories.TransformationRepository, TransformationD2ApiRepository); this.repositoryFactory.bind(Repositories.MappingRepository, MappingD2ApiRepository); this.repositoryFactory.bind(Repositories.SchedulerRepository, SchedulerD2ApiRepository); + this.repositoryFactory.bind(Repositories.SettingsRepository, SettingsD2ApiRepository); } @cache() @@ -387,6 +391,14 @@ export class CompositionRoot { updateSyncRule: new UpdateEmergencyResponseSyncRuleUseCase(this.repositoryFactory, this.localInstance), }); } + + @cache() + public get settings() { + return getExecute({ + get: new GetSettingsUseCase(this.repositoryFactory.settingsRepository(this.localInstance)), + save: new SaveSettingsUseCase(this.repositoryFactory.settingsRepository(this.localInstance)), + }); + } } function getExecute, Key extends keyof UseCases>( diff --git a/src/presentation/webapp/core/pages/settings/SettingsPage.tsx b/src/presentation/webapp/core/pages/settings/SettingsPage.tsx index ab8edd349..3a932af91 100644 --- a/src/presentation/webapp/core/pages/settings/SettingsPage.tsx +++ b/src/presentation/webapp/core/pages/settings/SettingsPage.tsx @@ -1,5 +1,5 @@ -import { ConfirmationDialog, useLoading } from "@eyeseetea/d2-ui-components"; -import { Button, FormGroup, makeStyles, Paper } from "@material-ui/core"; +import { ConfirmationDialog, useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; +import { Button, FormGroup, makeStyles, Paper, TextField } from "@material-ui/core"; import React, { useCallback, useEffect } from "react"; import { useHistory } from "react-router-dom"; import styled from "styled-components"; @@ -11,10 +11,22 @@ import { useSettings } from "./useSettings"; export const SettingsPage: React.FC = () => { const history = useHistory(); const classes = useStyles(); + const snackbar = useSnackbar(); const loading = useLoading(); - const { storageType, onChangeStorageType, onCancel, onSave, dialogProps, loadingMessage, goHome } = useSettings(); + const { + storageType, + settingsForm, + onChangeStorageType, + onChangeSettings, + onCancel, + onSave, + dialogProps, + loadingMessage, + goHome, + error, + } = useSettings(); const backHome = useCallback(() => history.push("/dashboard"), [history]); @@ -32,17 +44,41 @@ export const SettingsPage: React.FC = () => { } }, [backHome, goHome]); + useEffect(() => { + if (error) { + snackbar.error(error); + } + }, [error, snackbar]); + + const onChangeRetentionDays = useCallback( + (event: React.ChangeEvent<{ value: string }>) => { + onChangeSettings({ ...settingsForm, historyRetentionDays: event.target.value }); + }, + [onChangeSettings, settingsForm] + ); + return ( -

{i18n.t("Application storage")}

+

{i18n.t("Application storage")}

+

{i18n.t("History")}

+ + +