From 6f2a2bf7de04debb8e802832a6429a413bf7843f Mon Sep 17 00:00:00 2001 From: Matias Arriola Date: Tue, 31 Dec 2024 12:05:04 -0300 Subject: [PATCH 1/7] feat: Add custom header and footer. Hide dhis HeaderBar for non-admin users. --- README.md | 4 +++ src/app-config.ts | 28 +++++++++++++++ .../components/custom-footer/CustomFooter.tsx | 28 +++++++++++++++ .../components/custom-header/CustomHeader.tsx | 34 +++++++++++++++++++ src/webapp/pages/app/App.css | 16 +++++++++ src/webapp/pages/app/App.tsx | 7 ++-- 6 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 src/webapp/components/custom-footer/CustomFooter.tsx create mode 100644 src/webapp/components/custom-header/CustomHeader.tsx diff --git a/README.md b/README.md index 2be3ee5..8460ca2 100644 --- a/README.md +++ b/README.md @@ -181,3 +181,7 @@ Now you can start the server and check if every visualization is working properl ### Storage Settings can be saved in the data store (default) or as constants. Use the env variable **REACT_APP_STORAGE** to select which one to use (`datastore` or `constants`). + +### Custom Header and Footer + +The header and footer can be configured in `src/app-config.ts`. They can be disabled by setting their values to `false`. diff --git a/src/app-config.ts b/src/app-config.ts index 548def3..5002226 100644 --- a/src/app-config.ts +++ b/src/app-config.ts @@ -17,12 +17,40 @@ export const appConfig: AppConfig = { buttonPosition: "bottom-end", }, }, + header: false, + footer: false, + // header: { + // title: "Dashboard Reports - Custom Header Title", + // background: "rgba(19,52,59,1)", + // color: "white", + // }, + // footer: { + // text: `Dashboard Reports - Custom Footer. + // Multi-line text is allowed. + // TBD: More customization options. + // `, + // background: "linear-gradient(90deg, rgba(31,41,30,1) 0%, rgba(20,50,28,1) 50%, rgba(31,41,30,1) 100%)", + // color: "white", + // }, }; +export interface HeaderOptions { + title: string; + background?: string; + color?: string; +} +export interface FooterOptions { + text: string; + background?: string; + color?: string; +} + export interface AppConfig { appKey: string; appearance: { showShareButton: boolean; }; feedback?: FeedbackOptions; + header: HeaderOptions | false; + footer: FooterOptions | false; } diff --git a/src/webapp/components/custom-footer/CustomFooter.tsx b/src/webapp/components/custom-footer/CustomFooter.tsx new file mode 100644 index 0000000..e3fbaac --- /dev/null +++ b/src/webapp/components/custom-footer/CustomFooter.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Typography, Box, makeStyles } from "@material-ui/core"; +import { FooterOptions } from "../../../app-config"; + +export type CustomFooterProps = FooterOptions; + +type StyleProps = Omit; + +const useStyles = makeStyles(() => ({ + container: (props: StyleProps) => ({ + background: props.background ?? "inherit", + color: props.color ?? "inherit", + }), + text: { + whiteSpace: "pre-line", + }, +})); + +export const CustomFooter: React.FC = ({ text, ...styleProps }) => { + const classes = useStyles(styleProps); + return ( + + + {text} + + + ); +}; diff --git a/src/webapp/components/custom-header/CustomHeader.tsx b/src/webapp/components/custom-header/CustomHeader.tsx new file mode 100644 index 0000000..1346f8d --- /dev/null +++ b/src/webapp/components/custom-header/CustomHeader.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import AppBar from "@material-ui/core/AppBar"; +import Toolbar from "@material-ui/core/Toolbar"; +import Typography from "@material-ui/core/Typography"; +import { makeStyles } from "@material-ui/core/styles"; +import { HeaderOptions } from "../../../app-config"; + +export type CustomHeaderProps = HeaderOptions; + +type StyleProps = Omit; + +const useStyles = makeStyles(() => ({ + container: (props: StyleProps) => ({ + background: props.background ?? "inherit", + color: props.color ?? "inherit", + }), + title: { + flexGrow: 1, + }, +})); + +export const CustomHeader: React.FC = ({ title, ...styleProps }) => { + const classes = useStyles(styleProps); + + return ( + + + + {title} + + + + ); +}; diff --git a/src/webapp/pages/app/App.css b/src/webapp/pages/app/App.css index 4f0ed8f..5731e35 100644 --- a/src/webapp/pages/app/App.css +++ b/src/webapp/pages/app/App.css @@ -11,3 +11,19 @@ body { li { line-height: 1.75; } + +html, +body, +#root { + height: 100%; +} + +#root { + display: flex; + flex-direction: column; +} + +#root .content { + flex: 1; + padding-block-end: 1em; +} diff --git a/src/webapp/pages/app/App.tsx b/src/webapp/pages/app/App.tsx index 11fc6b4..54faf22 100644 --- a/src/webapp/pages/app/App.tsx +++ b/src/webapp/pages/app/App.tsx @@ -17,6 +17,8 @@ import "./App.css"; import muiThemeLegacy from "./themes/dhis2-legacy.theme"; import { muiTheme } from "./themes/dhis2.theme"; import _ from "lodash"; +import { CustomHeader } from "../../components/custom-header/CustomHeader"; +import { CustomFooter } from "../../components/custom-footer/CustomFooter"; export interface AppProps { api: D2Api; @@ -63,8 +65,8 @@ export const App: React.FC = React.memo(function App({ api, d2, instan - - + {appConfig.header && } + {appContext?.currentUser.isAdmin() ? : null} {appConfig.feedback && appContext && appContext.settings?.showFeedback && ( )} @@ -74,6 +76,7 @@ export const App: React.FC = React.memo(function App({ api, d2, instan + {appConfig.footer && } From 629d9258f0d0e8d2383c6750939ad84bb4d7fe06 Mon Sep 17 00:00:00 2001 From: Matias Arriola Date: Tue, 31 Dec 2024 13:15:48 -0300 Subject: [PATCH 2/7] feat: Move settings to its own page, just for Admins --- i18n/ar.po | 15 ++-- i18n/en.pot | 17 ++-- i18n/es.po | 15 ++-- i18n/fr.po | 15 ++-- i18n/pt.po | 15 ++-- .../dashboard-reports/DashboardReports.tsx | 51 +++--------- .../dashboard-settings/DashboardSettings.tsx | 83 ------------------- .../DashboardSettingsForm.tsx | 74 +++++++++++++++++ src/webapp/hooks/useSettings.ts | 3 + src/webapp/pages/Router.tsx | 10 ++- src/webapp/pages/settings/SettingsPage.tsx | 51 ++++++++++++ 11 files changed, 194 insertions(+), 155 deletions(-) delete mode 100644 src/webapp/components/dashboard-settings/DashboardSettings.tsx create mode 100644 src/webapp/components/dashboard-settings/DashboardSettingsForm.tsx create mode 100644 src/webapp/pages/settings/SettingsPage.tsx diff --git a/i18n/ar.po b/i18n/ar.po index 6ab2a8b..d9c9329 100644 --- a/i18n/ar.po +++ b/i18n/ar.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-12-16T17:57:18.316Z\n" +"POT-Creation-Date: 2024-12-31T16:17:51.096Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -26,19 +26,16 @@ msgstr "" msgid "Export to Word" msgstr "" -msgid "App Settings" -msgstr "" - msgid "Font Size" msgstr "" msgid "Show/Hide Feedback" msgstr "" -msgid "Save" +msgid "Cancel" msgstr "" -msgid "Close" +msgid "Save" msgstr "" msgid "Back" @@ -53,6 +50,9 @@ msgstr "" msgid "Templates not found" msgstr "" +msgid "Settings saved" +msgstr "" + msgid "Distributed under GNU GLPv3" msgstr "" @@ -81,3 +81,6 @@ msgstr "" msgid "EyeSeeTea" msgstr "" + +msgid "App Settings" +msgstr "" diff --git a/i18n/en.pot b/i18n/en.pot index 0712d06..33fcbd0 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-12-16T17:57:18.316Z\n" -"PO-Revision-Date: 2024-12-16T17:57:18.316Z\n" +"POT-Creation-Date: 2024-12-31T16:17:51.096Z\n" +"PO-Revision-Date: 2024-12-31T16:17:51.096Z\n" msgid "Select Dashboard" msgstr "" @@ -26,19 +26,16 @@ msgstr "" msgid "Export to Word" msgstr "" -msgid "App Settings" -msgstr "" - msgid "Font Size" msgstr "" msgid "Show/Hide Feedback" msgstr "" -msgid "Save" +msgid "Cancel" msgstr "" -msgid "Close" +msgid "Save" msgstr "" msgid "Back" @@ -53,6 +50,9 @@ msgstr "" msgid "Templates not found" msgstr "" +msgid "Settings saved" +msgstr "" + msgid "Distributed under GNU GLPv3" msgstr "" @@ -81,3 +81,6 @@ msgstr "" msgid "EyeSeeTea" msgstr "" + +msgid "App Settings" +msgstr "" diff --git a/i18n/es.po b/i18n/es.po index b8a2c9e..89593b7 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-12-16T17:57:18.316Z\n" +"POT-Creation-Date: 2024-12-31T16:17:51.096Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -26,19 +26,16 @@ msgstr "" msgid "Export to Word" msgstr "" -msgid "App Settings" -msgstr "" - msgid "Font Size" msgstr "" msgid "Show/Hide Feedback" msgstr "" -msgid "Save" +msgid "Cancel" msgstr "" -msgid "Close" +msgid "Save" msgstr "" msgid "Back" @@ -53,6 +50,9 @@ msgstr "" msgid "Templates not found" msgstr "" +msgid "Settings saved" +msgstr "" + msgid "Distributed under GNU GLPv3" msgstr "" @@ -82,6 +82,9 @@ msgstr "" msgid "EyeSeeTea" msgstr "" +msgid "App Settings" +msgstr "" + #~ msgid "Add" #~ msgstr "AƱadir" diff --git a/i18n/fr.po b/i18n/fr.po index 6ab2a8b..d9c9329 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-12-16T17:57:18.316Z\n" +"POT-Creation-Date: 2024-12-31T16:17:51.096Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -26,19 +26,16 @@ msgstr "" msgid "Export to Word" msgstr "" -msgid "App Settings" -msgstr "" - msgid "Font Size" msgstr "" msgid "Show/Hide Feedback" msgstr "" -msgid "Save" +msgid "Cancel" msgstr "" -msgid "Close" +msgid "Save" msgstr "" msgid "Back" @@ -53,6 +50,9 @@ msgstr "" msgid "Templates not found" msgstr "" +msgid "Settings saved" +msgstr "" + msgid "Distributed under GNU GLPv3" msgstr "" @@ -81,3 +81,6 @@ msgstr "" msgid "EyeSeeTea" msgstr "" + +msgid "App Settings" +msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index 6ab2a8b..d9c9329 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-12-16T17:57:18.316Z\n" +"POT-Creation-Date: 2024-12-31T16:17:51.096Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -26,19 +26,16 @@ msgstr "" msgid "Export to Word" msgstr "" -msgid "App Settings" -msgstr "" - msgid "Font Size" msgstr "" msgid "Show/Hide Feedback" msgstr "" -msgid "Save" +msgid "Cancel" msgstr "" -msgid "Close" +msgid "Save" msgstr "" msgid "Back" @@ -53,6 +50,9 @@ msgstr "" msgid "Templates not found" msgstr "" +msgid "Settings saved" +msgstr "" + msgid "Distributed under GNU GLPv3" msgstr "" @@ -81,3 +81,6 @@ msgstr "" msgid "EyeSeeTea" msgstr "" + +msgid "App Settings" +msgstr "" diff --git a/src/webapp/components/dashboard-reports/DashboardReports.tsx b/src/webapp/components/dashboard-reports/DashboardReports.tsx index bd600f4..5fd7775 100644 --- a/src/webapp/components/dashboard-reports/DashboardReports.tsx +++ b/src/webapp/components/dashboard-reports/DashboardReports.tsx @@ -10,24 +10,22 @@ import Typography from "@material-ui/core/Typography"; import { useSnackbar, useLoading } from "@eyeseetea/d2-ui-components"; import { DashboardItem } from "../../../domain/entities/Dashboard"; import { DashboardFilter, DashboardFilterData } from "../../components/dashboard-filter/DashboardFilter"; -import { DashboardSettings } from "../../components/dashboard-settings/DashboardSettings"; import i18n from "../../../locales"; -import { Settings, TemplateReport } from "../../../domain/entities/Settings"; +import { TemplateReport } from "../../../domain/entities/Settings"; import { useDashboard } from "../../hooks/useDashboard"; -import { useSettings } from "../../hooks/useSettings"; import { useGenerateDocxReport } from "../../hooks/useGenerateDocxReport"; import { useAppContext } from "../../contexts/app-context"; import { Visualization } from "../visualization/Visualization"; +import { Link } from "react-router-dom"; export const DashboardReports: React.FC = React.memo(() => { const appContext = useAppContext(); const snackbar = useSnackbar(); const loading = useLoading(); const settings = appContext.settings; + const isAdmin = appContext.currentUser.isAdmin(); const { dashboards } = useDashboard(); const [selectedReport, setSelectedReport] = React.useState(settings?.templates[0]); - const { saveSettings } = useSettings(settings?.templates[0]); - const [dialogState, setDialogState] = React.useState(false); const [dashboard, setDashboard] = React.useState(); const { generateDocxReport } = useGenerateDocxReport({ dashboard, settings }); @@ -37,26 +35,6 @@ export const DashboardReports: React.FC = React.memo(() => { setDashboard(dashboardFilter); }; - const onSettings = () => { - setDialogState(true); - }; - - const closeDialog = () => { - setDialogState(false); - }; - - const onSubmitSettings = (settings: Settings) => { - saveSettings(settings); - setDialogState(false); - appContext.setAppContext(prev => { - if (!prev) return null; - return { - ...prev, - settings, - }; - }); - }; - const onChangeExport = (event: React.ChangeEvent<{ value: unknown }>) => { const value = event.target.value as string; const template = settings?.templates.find(t => t.name === value); @@ -115,11 +93,15 @@ export const DashboardReports: React.FC = React.memo(() => { )} - - - - - + {isAdmin && ( + + + + + + + + )} @@ -141,15 +123,6 @@ export const DashboardReports: React.FC = React.memo(() => { })} - - {settings && dialogState && ( - - )} ); }); diff --git a/src/webapp/components/dashboard-settings/DashboardSettings.tsx b/src/webapp/components/dashboard-settings/DashboardSettings.tsx deleted file mode 100644 index f4bde3b..0000000 --- a/src/webapp/components/dashboard-settings/DashboardSettings.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from "react"; -import DialogTitle from "@material-ui/core/DialogTitle"; -import DialogActions from "@material-ui/core/DialogActions"; -import DialogContent from "@material-ui/core/DialogContent"; -import Dialog from "@material-ui/core/Dialog"; -import Button from "@material-ui/core/Button"; -import TextField from "@material-ui/core/TextField"; -import Checkbox from "@material-ui/core/Checkbox"; -import FormControlLabel from "@material-ui/core/FormControlLabel"; -import FormGroup from "@material-ui/core/FormGroup"; -import { Settings } from "../../../domain/entities/Settings"; -import i18n from "../../../locales"; - -export const DashboardSettings: React.FC = React.memo( - ({ dialogState, onSubmitForm, onDialogClose, settings }) => { - const onSubmit: React.FormEventHandler = e => { - e.preventDefault(); - const formElements = e.target as typeof e.target & { - fontSize: { value: string }; - showFeedback: { checked: boolean }; - }; - - const settingsToSave: Settings = { - id: settings.id, - fontSize: formElements.fontSize.value, - templates: settings.templates, - showFeedback: formElements.showFeedback.checked, - }; - onSubmitForm(settingsToSave); - }; - - return ( - - {i18n.t("App Settings")} - - -
- - - - - } - label={i18n.t("Show/Hide Feedback")} - /> - -
-
- - - - - - -
- ); - } -); - -export interface DashboardSettingsProps { - settings: Settings; - dialogState: boolean; - onDialogClose: () => void; - onSubmitForm: (settings: Settings) => void; -} - -DashboardSettings.displayName = "DashboardSettings"; diff --git a/src/webapp/components/dashboard-settings/DashboardSettingsForm.tsx b/src/webapp/components/dashboard-settings/DashboardSettingsForm.tsx new file mode 100644 index 0000000..9736317 --- /dev/null +++ b/src/webapp/components/dashboard-settings/DashboardSettingsForm.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import Button from "@material-ui/core/Button"; +import TextField from "@material-ui/core/TextField"; +import Checkbox from "@material-ui/core/Checkbox"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import FormGroup from "@material-ui/core/FormGroup"; +import { Settings } from "../../../domain/entities/Settings"; +import i18n from "../../../locales"; +import styled from "styled-components"; + +export const DashboardSettingsForm: React.FC = React.memo( + ({ onSubmitForm, settings, onCancel }) => { + const onSubmit: React.FormEventHandler = e => { + e.preventDefault(); + const formElements = e.target as typeof e.target & { + fontSize: { value: string }; + showFeedback: { checked: boolean }; + }; + + const settingsToSave: Settings = { + id: settings.id, + fontSize: formElements.fontSize.value, + templates: settings.templates, + showFeedback: formElements.showFeedback.checked, + }; + onSubmitForm(settingsToSave); + }; + return ( +
+ + + + } + label={i18n.t("Show/Hide Feedback")} + /> + + + + + {i18n.t("Save")} + + +
+ ); + } +); +const ActionsContainer = styled.div` + display: flex; + gap: 10px; + justify-content: flex-end; +`; + +const SaveButton = styled(Button)` + min-width: 100px; +`; + +export interface DashboardSettingsFormProps { + settings: Settings; + onSubmitForm: (settings: Settings) => void; + onCancel: () => void; +} + +DashboardSettingsForm.displayName = "DashboardSettingsForm"; diff --git a/src/webapp/hooks/useSettings.ts b/src/webapp/hooks/useSettings.ts index 4d7fdb2..79d5cb0 100644 --- a/src/webapp/hooks/useSettings.ts +++ b/src/webapp/hooks/useSettings.ts @@ -21,6 +21,9 @@ export function useSettings(template: TemplateReport | undefined) { compositionRoot.settings.save.execute(settings).run( () => { loading.hide(); + snackbar.openSnackbar("success", i18n.t("Settings saved"), { + autoHideDuration: 3000, + }); }, err => { snackbar.openSnackbar("error", err); diff --git a/src/webapp/pages/Router.tsx b/src/webapp/pages/Router.tsx index a30a841..a4defe0 100644 --- a/src/webapp/pages/Router.tsx +++ b/src/webapp/pages/Router.tsx @@ -1,15 +1,21 @@ import React from "react"; -import { HashRouter, Route, Switch } from "react-router-dom"; +import { HashRouter, Route, Switch, Redirect } from "react-router-dom"; import { AboutButtonFloat } from "./about/AboutButtonFloat"; import { AboutPage } from "./about/AboutPage"; import { LandingPage } from "./landing/LandingPage"; +import { SettingsPage } from "./settings/SettingsPage"; +import { useAppContext } from "../contexts/app-context"; export const Router: React.FC = React.memo(() => { + const { currentUser } = useAppContext(); return ( - {/* Default route */} } /> + (currentUser.isAdmin() ? : )} + /> } /> diff --git a/src/webapp/pages/settings/SettingsPage.tsx b/src/webapp/pages/settings/SettingsPage.tsx new file mode 100644 index 0000000..ba409ee --- /dev/null +++ b/src/webapp/pages/settings/SettingsPage.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import styled from "styled-components"; +import { useHistory } from "react-router-dom"; +import i18n from "../../../locales"; +import { PageHeader } from "../../components/page-header/PageHeader"; +import { useSettings } from "../../hooks/useSettings"; +import { useAppContext } from "../../contexts/app-context"; +import { Settings } from "../../../domain/entities/Settings"; +import { DashboardSettingsForm } from "../../components/dashboard-settings/DashboardSettingsForm"; + +export const SettingsPage: React.FC = React.memo(() => { + const history = useHistory(); + const appContext = useAppContext(); + + const settings = appContext.settings; + const goBack = React.useCallback(() => { + history.goBack(); + }, [history]); + + const { saveSettings } = useSettings(settings?.templates[0]); + + const onSubmitSettings = (settings: Settings) => { + saveSettings(settings); + appContext.setAppContext(prev => { + if (!prev) return null; + return { + ...prev, + settings, + }; + }); + }; + return ( +
+ + + + + + +
+ ); +}); + +const PageHeaderContainer = styled.div` + padding-top: 10px; +`; + +const FormContainer = styled.div` + padding: 20px 30px; + max-width: 500px; +`; From d3195d5eb12bee7244f060894a52ed35403ae9f8 Mon Sep 17 00:00:00 2001 From: Matias Arriola Date: Tue, 7 Jan 2025 14:25:58 -0300 Subject: [PATCH 3/7] fix: Moved config options to readme instead of having commented code --- README.md | 21 +++++++++++++++++++++ src/app-config.ts | 13 ------------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8460ca2..27eccf5 100644 --- a/README.md +++ b/README.md @@ -185,3 +185,24 @@ Settings can be saved in the data store (default) or as constants. Use the env v ### Custom Header and Footer The header and footer can be configured in `src/app-config.ts`. They can be disabled by setting their values to `false`. +See `HeaderOptions` and `FooterOptions` types for supported options. + +Example config: + +```typescript +{ + header: { + title: "Dashboard Reports - Custom Header Title", + background: "rgba(19,52,59,1)", + color: "white", + }, + footer: { + text: `Dashboard Reports - Custom Footer. + Multi-line text is allowed. + TBD: More customization options. + `, + background: "linear-gradient(90deg, rgba(31,41,30,1) 0%, rgba(20,50,28,1) 50%, rgba(31,41,30,1) 100%)", + color: "white", + } +} +``` diff --git a/src/app-config.ts b/src/app-config.ts index 5002226..c540d01 100644 --- a/src/app-config.ts +++ b/src/app-config.ts @@ -19,19 +19,6 @@ export const appConfig: AppConfig = { }, header: false, footer: false, - // header: { - // title: "Dashboard Reports - Custom Header Title", - // background: "rgba(19,52,59,1)", - // color: "white", - // }, - // footer: { - // text: `Dashboard Reports - Custom Footer. - // Multi-line text is allowed. - // TBD: More customization options. - // `, - // background: "linear-gradient(90deg, rgba(31,41,30,1) 0%, rgba(20,50,28,1) 50%, rgba(31,41,30,1) 100%)", - // color: "white", - // }, }; export interface HeaderOptions { From 1780c983627c4a4584b3f0ada330418cd0880773 Mon Sep 17 00:00:00 2001 From: Matias Arriola Date: Wed, 8 Jan 2025 14:03:32 -0300 Subject: [PATCH 4/7] fix: check for admin in SaveSettingsUseCase refactor: create a separate new UseCase for default settings initialization and move default settings fetch to its own repository --- src/CompositionRoot.ts | 4 +++ .../DefaultSettingsHTTPRepository.ts | 11 ++++++++ src/domain/entities/Settings.ts | 4 +++ .../repositories/DefaultSettingsRepository.ts | 6 +++++ .../usecases/InitDefaultSettingsUseCase.ts | 17 ++++++++++++ src/domain/usecases/SaveSettingsUseCase.ts | 11 +++++--- src/utils/futures.ts | 26 +++++++++++++++++++ src/webapp/hooks/useSettings.ts | 6 ++--- src/webapp/pages/app/App.tsx | 22 +++++++--------- 9 files changed, 89 insertions(+), 18 deletions(-) create mode 100644 src/data/repositories/DefaultSettingsHTTPRepository.ts create mode 100644 src/domain/repositories/DefaultSettingsRepository.ts create mode 100644 src/domain/usecases/InitDefaultSettingsUseCase.ts diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 903b380..275284e 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -15,6 +15,8 @@ import { StorageName } from "./domain/entities/Settings"; import { DataStoreD2Repository } from "./data/repositories/DataStoreD2Repository"; import { PluginVisualizationD2Repository } from "./data/repositories/PluginVisualizationD2Repository"; import { GetPluginVisualizationUseCase } from "./domain/usecases/GetPluginVisualizationUseCase"; +import { InitDefaultSettingsUseCase } from "./domain/usecases/InitDefaultSettingsUseCase"; +import { DefaultSettingsHTTPRepository } from "./data/repositories/DefaultSettingsHTTPRepository"; export function getCompositionRoot(api: D2Api, instance: Instance, storageName: StorageName) { const instanceRepository = new InstanceDefaultRepository(instance); @@ -24,6 +26,7 @@ export function getCompositionRoot(api: D2Api, instance: Instance, storageName: storageName === "datastore" ? new DataStoreD2Repository(api) : new SettingsD2ConstantRepository(api); const exportDocxRepository = new DashboardExportDocxRepository(); const pluginVisualizationsRepository = new PluginVisualizationD2Repository(api); + const defaultSettingsRepository = new DefaultSettingsHTTPRepository(); return { instance: { @@ -41,6 +44,7 @@ export function getCompositionRoot(api: D2Api, instance: Instance, storageName: settings: { get: new GetSettingsUseCase(settingsRepository), save: new SaveSettingsUseCase(settingsRepository), + initDefaults: new InitDefaultSettingsUseCase(settingsRepository, defaultSettingsRepository), }, export: { save: new SaveRawReportUseCase(exportDocxRepository), diff --git a/src/data/repositories/DefaultSettingsHTTPRepository.ts b/src/data/repositories/DefaultSettingsHTTPRepository.ts new file mode 100644 index 0000000..2d7c2f4 --- /dev/null +++ b/src/data/repositories/DefaultSettingsHTTPRepository.ts @@ -0,0 +1,11 @@ +import { FutureData } from "../../domain/entities/Future"; +import { Settings } from "../../domain/entities/Settings"; +import { DefaultSettingsRepository } from "../../domain/repositories/DefaultSettingsRepository"; +import { getJSON } from "../../utils/futures"; + +export class DefaultSettingsHTTPRepository implements DefaultSettingsRepository { + public get(): FutureData { + const DEFAULT_SETTINGS_URL = "default-settings.json"; + return getJSON(DEFAULT_SETTINGS_URL); + } +} diff --git a/src/domain/entities/Settings.ts b/src/domain/entities/Settings.ts index 0a03973..9590e92 100644 --- a/src/domain/entities/Settings.ts +++ b/src/domain/entities/Settings.ts @@ -26,4 +26,8 @@ export function getDefaultValues(): Omit { }; } +export function areSettingsInitialized(settings: Settings): boolean { + return !!settings.id && settings.templates.length > 0; +} + export type StorageName = "constants" | "datastore"; diff --git a/src/domain/repositories/DefaultSettingsRepository.ts b/src/domain/repositories/DefaultSettingsRepository.ts new file mode 100644 index 0000000..0187d70 --- /dev/null +++ b/src/domain/repositories/DefaultSettingsRepository.ts @@ -0,0 +1,6 @@ +import { FutureData } from "../entities/Future"; +import { Settings } from "../entities/Settings"; + +export interface DefaultSettingsRepository { + get(): FutureData; +} diff --git a/src/domain/usecases/InitDefaultSettingsUseCase.ts b/src/domain/usecases/InitDefaultSettingsUseCase.ts new file mode 100644 index 0000000..746e52e --- /dev/null +++ b/src/domain/usecases/InitDefaultSettingsUseCase.ts @@ -0,0 +1,17 @@ +import { Future, FutureData } from "../entities/Future"; +import { Settings } from "../entities/Settings"; +import { SettingsRepository } from "../repositories/SettingsRepository"; +import { DefaultSettingsRepository } from "../repositories/DefaultSettingsRepository"; + +export class InitDefaultSettingsUseCase { + constructor( + private settingsRepository: SettingsRepository, + private defaultSettingsRepository: DefaultSettingsRepository + ) {} + + public execute(): FutureData { + return this.defaultSettingsRepository.get().flatMap(defaultSettings => { + return this.settingsRepository.save(defaultSettings).flatMap(() => Future.success(defaultSettings)); + }); + } +} diff --git a/src/domain/usecases/SaveSettingsUseCase.ts b/src/domain/usecases/SaveSettingsUseCase.ts index 7b126f4..ff28401 100644 --- a/src/domain/usecases/SaveSettingsUseCase.ts +++ b/src/domain/usecases/SaveSettingsUseCase.ts @@ -1,11 +1,16 @@ -import { FutureData } from "../entities/Future"; +import { Future, FutureData } from "../entities/Future"; import { Settings } from "../entities/Settings"; +import { User } from "../entities/User"; import { SettingsRepository } from "../repositories/SettingsRepository"; +import i18n from "../../locales"; export class SaveSettingsUseCase { constructor(private settingsRepository: SettingsRepository) {} - public execute(settings: Settings): FutureData { - return this.settingsRepository.save(settings); + public execute(options: { settings: Settings; currentUser: User }): FutureData { + if (!options.currentUser.isAdmin()) { + return Future.error(i18n.t("Not authorized: only admins can save settings")); + } + return this.settingsRepository.save(options.settings); } } diff --git a/src/utils/futures.ts b/src/utils/futures.ts index 34f843c..4e78813 100644 --- a/src/utils/futures.ts +++ b/src/utils/futures.ts @@ -9,3 +9,29 @@ export function apiToFuture(res: CancelableResponse): FutureData(url: string): FutureData { + const abortController = new AbortController(); + + return Future.fromComputation((resolve, reject) => { + // exceptions: TypeError | DOMException[name=AbortError] + fetch(url, { method: "get", signal: abortController.signal }) + .then(res => res.json() as Data) // exceptions: SyntaxError + .then(data => resolve(data)) + .catch((error: unknown) => { + if (isNamedError(error) && error.name === "AbortError") { + return reject("AbortError"); + } else if (error instanceof TypeError || error instanceof SyntaxError) { + reject(error.message); + } else { + reject("Unknown error"); + } + }); + + return () => abortController.abort(); + }); +} + +function isNamedError(error: unknown): error is { name: string } { + return Boolean(error && typeof error === "object" && "name" in error); +} diff --git a/src/webapp/hooks/useSettings.ts b/src/webapp/hooks/useSettings.ts index 79d5cb0..bb147d5 100644 --- a/src/webapp/hooks/useSettings.ts +++ b/src/webapp/hooks/useSettings.ts @@ -7,7 +7,7 @@ import i18n from "../../locales"; export function useSettings(template: TemplateReport | undefined) { const snackbar = useSnackbar(); const loading = useLoading(); - const { compositionRoot } = useAppContext(); + const { compositionRoot, currentUser } = useAppContext(); React.useEffect(() => { if (!template) { @@ -18,7 +18,7 @@ export function useSettings(template: TemplateReport | undefined) { const saveSettings = React.useCallback( (settings: Settings) => { loading.show(); - compositionRoot.settings.save.execute(settings).run( + compositionRoot.settings.save.execute({ settings, currentUser }).run( () => { loading.hide(); snackbar.openSnackbar("success", i18n.t("Settings saved"), { @@ -31,7 +31,7 @@ export function useSettings(template: TemplateReport | undefined) { } ); }, - [loading, compositionRoot, snackbar] + [loading, compositionRoot, snackbar, currentUser] ); return { diff --git a/src/webapp/pages/app/App.tsx b/src/webapp/pages/app/App.tsx index 54faf22..773a7ca 100644 --- a/src/webapp/pages/app/App.tsx +++ b/src/webapp/pages/app/App.tsx @@ -8,7 +8,7 @@ import React, { useEffect, useState } from "react"; import { appConfig } from "../../../app-config"; import { getCompositionRoot } from "../../../CompositionRoot"; import { Instance } from "../../../data/entities/Instance"; -import { Settings, StorageName } from "../../../domain/entities/Settings"; +import { areSettingsInitialized, Settings, StorageName } from "../../../domain/entities/Settings"; import { D2Api } from "../../../types/d2-api"; import { Maybe } from "../../../types/utils"; import { AppContext, AppContextState } from "../../contexts/app-context"; @@ -26,13 +26,6 @@ export interface AppProps { instance: Instance; } -function getSettings(settingsFromStorage: Maybe, defaultSettings: Settings): Settings { - if (!settingsFromStorage) throw new Error("Cannot load settings"); - const hasTemplates = settingsFromStorage ? settingsFromStorage.templates.length > 0 : false; - const settings = hasTemplates ? settingsFromStorage : defaultSettings; - return settings; -} - export const App: React.FC = React.memo(function App({ api, d2, instance }) { const [loading, setLoading] = useState(true); const [appContext, setAppContext] = useState(null); @@ -40,15 +33,20 @@ export const App: React.FC = React.memo(function App({ api, d2, instan useEffect(() => { async function setup() { const isDev = process.env.NODE_ENV === "development"; - const defaultSettings = await fetch("default-settings.json").then(res => res.json()); const storageName = (process.env.REACT_APP_STORAGE as Maybe) || "datastore"; const compositionRoot = getCompositionRoot(api, instance, storageName); const currentUser = (await compositionRoot.users.getCurrent.execute().runAsync()).data; const settingsFromStorage = (await compositionRoot.settings.get.execute().runAsync()).data; - const settings = getSettings(settingsFromStorage, defaultSettings); const pluginVersion = `${_.get(d2, "system.version.major")}${_.get(d2, "system.version.minor")}`; - if (!settings.id) { - await compositionRoot.settings.save.execute(settings).runAsync(); + let settings: Settings; + if (!settingsFromStorage) { + throw new Error("Cannot load settings"); + } else if (!areSettingsInitialized(settingsFromStorage)) { + const defaultSettings = (await compositionRoot.settings.initDefaults.execute().runAsync()).data; + if (!defaultSettings) throw new Error("Error initializing default settings"); + settings = defaultSettings; + } else { + settings = settingsFromStorage; } if (!currentUser) throw new Error("User not logged in"); From 0bef823afd773450ee753f341c6ed770a968ea1c Mon Sep 17 00:00:00 2001 From: Matias Arriola Date: Wed, 8 Jan 2025 14:05:49 -0300 Subject: [PATCH 5/7] fix: avoid i18n namespace error when using ":" in translation key --- i18n/ar.po | 5 ++++- i18n/en.pot | 7 +++++-- i18n/es.po | 5 ++++- i18n/fr.po | 5 ++++- i18n/pt.po | 5 ++++- src/domain/usecases/SaveSettingsUseCase.ts | 2 +- 6 files changed, 22 insertions(+), 7 deletions(-) diff --git a/i18n/ar.po b/i18n/ar.po index d9c9329..283c978 100644 --- a/i18n/ar.po +++ b/i18n/ar.po @@ -1,13 +1,16 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-12-31T16:17:51.096Z\n" +"POT-Creation-Date: 2025-01-08T17:04:38.103Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" +msgid "Not authorized - only admins can save settings" +msgstr "" + msgid "Select Dashboard" msgstr "" diff --git a/i18n/en.pot b/i18n/en.pot index 33fcbd0..74c90b1 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,11 @@ 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-12-31T16:17:51.096Z\n" -"PO-Revision-Date: 2024-12-31T16:17:51.096Z\n" +"POT-Creation-Date: 2025-01-08T17:04:38.103Z\n" +"PO-Revision-Date: 2025-01-08T17:04:38.103Z\n" + +msgid "Not authorized - only admins can save settings" +msgstr "" msgid "Select Dashboard" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 89593b7..75e7d68 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,13 +1,16 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-12-31T16:17:51.096Z\n" +"POT-Creation-Date: 2025-01-08T17:04:38.103Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" +msgid "Not authorized - only admins can save settings" +msgstr "" + msgid "Select Dashboard" msgstr "Seleccionar panel" diff --git a/i18n/fr.po b/i18n/fr.po index d9c9329..283c978 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,13 +1,16 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-12-31T16:17:51.096Z\n" +"POT-Creation-Date: 2025-01-08T17:04:38.103Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" +msgid "Not authorized - only admins can save settings" +msgstr "" + msgid "Select Dashboard" msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index d9c9329..283c978 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,13 +1,16 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-12-31T16:17:51.096Z\n" +"POT-Creation-Date: 2025-01-08T17:04:38.103Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" +msgid "Not authorized - only admins can save settings" +msgstr "" + msgid "Select Dashboard" msgstr "" diff --git a/src/domain/usecases/SaveSettingsUseCase.ts b/src/domain/usecases/SaveSettingsUseCase.ts index ff28401..b710ca6 100644 --- a/src/domain/usecases/SaveSettingsUseCase.ts +++ b/src/domain/usecases/SaveSettingsUseCase.ts @@ -9,7 +9,7 @@ export class SaveSettingsUseCase { public execute(options: { settings: Settings; currentUser: User }): FutureData { if (!options.currentUser.isAdmin()) { - return Future.error(i18n.t("Not authorized: only admins can save settings")); + return Future.error(i18n.t("Not authorized - only admins can save settings")); } return this.settingsRepository.save(options.settings); } From 5df528b0e22e6c844df9f77da7ca5f1609c4c303 Mon Sep 17 00:00:00 2001 From: Matias Arriola Date: Wed, 8 Jan 2025 14:38:55 -0300 Subject: [PATCH 6/7] fix: i18n duplicated keys --- i18n/ar.po | 3 --- i18n/en.pot | 3 --- i18n/es.po | 3 --- i18n/fr.po | 6 ------ i18n/pt.po | 8 +------- 5 files changed, 1 insertion(+), 22 deletions(-) diff --git a/i18n/ar.po b/i18n/ar.po index 7c42500..12360dd 100644 --- a/i18n/ar.po +++ b/i18n/ar.po @@ -50,9 +50,6 @@ msgstr "" msgid "Select Organization Units" msgstr "" -msgid "Cancel" -msgstr "" - msgid "Select" msgstr "" diff --git a/i18n/en.pot b/i18n/en.pot index 2c0593f..40b7f0d 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -50,9 +50,6 @@ msgstr "" msgid "Select Organization Units" msgstr "" -msgid "Cancel" -msgstr "" - msgid "Select" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 30125ce..8b629ce 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -50,9 +50,6 @@ msgstr "" msgid "Select Organization Units" msgstr "" -msgid "Cancel" -msgstr "" - msgid "Select" msgstr "" diff --git a/i18n/fr.po b/i18n/fr.po index 786d653..12360dd 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -41,9 +41,6 @@ msgstr "" msgid "Save" msgstr "" -msgid "Close" -msgstr "" - msgid "Loading..." msgstr "" @@ -53,9 +50,6 @@ msgstr "" msgid "Select Organization Units" msgstr "" -msgid "Cancel" -msgstr "" - msgid "Select" msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index cd89ff5..12360dd 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-12-30T12:25:50.318Z\n" +"POT-Creation-Date: 2025-01-08T17:04:38.103Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -41,9 +41,6 @@ msgstr "" msgid "Save" msgstr "" -msgid "Close" -msgstr "" - msgid "Loading..." msgstr "" @@ -53,9 +50,6 @@ msgstr "" msgid "Select Organization Units" msgstr "" -msgid "Cancel" -msgstr "" - msgid "Select" msgstr "" From 4d5f71deb986cc311a2b96ecb2820140c9fbd55a Mon Sep 17 00:00:00 2001 From: Matias Arriola Date: Thu, 9 Jan 2025 08:28:41 -0300 Subject: [PATCH 7/7] refactor: extract function getSettingsOrInitialize Convert unnecesary flatMap to map --- .../usecases/InitDefaultSettingsUseCase.ts | 4 +-- src/webapp/pages/app/App.tsx | 27 ++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/domain/usecases/InitDefaultSettingsUseCase.ts b/src/domain/usecases/InitDefaultSettingsUseCase.ts index 746e52e..8f99187 100644 --- a/src/domain/usecases/InitDefaultSettingsUseCase.ts +++ b/src/domain/usecases/InitDefaultSettingsUseCase.ts @@ -1,4 +1,4 @@ -import { Future, FutureData } from "../entities/Future"; +import { FutureData } from "../entities/Future"; import { Settings } from "../entities/Settings"; import { SettingsRepository } from "../repositories/SettingsRepository"; import { DefaultSettingsRepository } from "../repositories/DefaultSettingsRepository"; @@ -11,7 +11,7 @@ export class InitDefaultSettingsUseCase { public execute(): FutureData { return this.defaultSettingsRepository.get().flatMap(defaultSettings => { - return this.settingsRepository.save(defaultSettings).flatMap(() => Future.success(defaultSettings)); + return this.settingsRepository.save(defaultSettings).map(() => defaultSettings); }); } } diff --git a/src/webapp/pages/app/App.tsx b/src/webapp/pages/app/App.tsx index 773a7ca..bb31d8c 100644 --- a/src/webapp/pages/app/App.tsx +++ b/src/webapp/pages/app/App.tsx @@ -6,7 +6,7 @@ import { MuiThemeProvider } from "@material-ui/core/styles"; import OldMuiThemeProvider from "material-ui/styles/MuiThemeProvider"; import React, { useEffect, useState } from "react"; import { appConfig } from "../../../app-config"; -import { getCompositionRoot } from "../../../CompositionRoot"; +import { CompositionRoot, getCompositionRoot } from "../../../CompositionRoot"; import { Instance } from "../../../data/entities/Instance"; import { areSettingsInitialized, Settings, StorageName } from "../../../domain/entities/Settings"; import { D2Api } from "../../../types/d2-api"; @@ -26,6 +26,19 @@ export interface AppProps { instance: Instance; } +async function getSettingsOrInitialize(compositionRoot: CompositionRoot): Promise { + const settingsFromStorage = (await compositionRoot.settings.get.execute().runAsync()).data; + if (!settingsFromStorage) { + throw new Error("Cannot load settings"); + } else if (!areSettingsInitialized(settingsFromStorage)) { + const defaultSettings = (await compositionRoot.settings.initDefaults.execute().runAsync()).data; + if (!defaultSettings) throw new Error("Error initializing default settings"); + return defaultSettings; + } else { + return settingsFromStorage; + } +} + export const App: React.FC = React.memo(function App({ api, d2, instance }) { const [loading, setLoading] = useState(true); const [appContext, setAppContext] = useState(null); @@ -36,18 +49,8 @@ export const App: React.FC = React.memo(function App({ api, d2, instan const storageName = (process.env.REACT_APP_STORAGE as Maybe) || "datastore"; const compositionRoot = getCompositionRoot(api, instance, storageName); const currentUser = (await compositionRoot.users.getCurrent.execute().runAsync()).data; - const settingsFromStorage = (await compositionRoot.settings.get.execute().runAsync()).data; const pluginVersion = `${_.get(d2, "system.version.major")}${_.get(d2, "system.version.minor")}`; - let settings: Settings; - if (!settingsFromStorage) { - throw new Error("Cannot load settings"); - } else if (!areSettingsInitialized(settingsFromStorage)) { - const defaultSettings = (await compositionRoot.settings.initDefaults.execute().runAsync()).data; - if (!defaultSettings) throw new Error("Error initializing default settings"); - settings = defaultSettings; - } else { - settings = settingsFromStorage; - } + const settings = await getSettingsOrInitialize(compositionRoot); if (!currentUser) throw new Error("User not logged in"); setAppContext({ api, currentUser, compositionRoot, isDev, settings, setAppContext, pluginVersion });