diff --git a/README.md b/README.md index 2be3ee5..27eccf5 100644 --- a/README.md +++ b/README.md @@ -181,3 +181,28 @@ 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`. +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/i18n/ar.po b/i18n/ar.po index a0a83c9..12360dd 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-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" "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 "" @@ -26,19 +29,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 "Loading..." @@ -50,9 +50,6 @@ msgstr "" msgid "Select Organization Units" msgstr "" -msgid "Cancel" -msgstr "" - msgid "Select" msgstr "" @@ -71,6 +68,9 @@ msgstr "" msgid "Templates not found" msgstr "" +msgid "Settings saved" +msgstr "" + msgid "Distributed under GNU GLPv3" msgstr "" @@ -99,3 +99,6 @@ msgstr "" msgid "EyeSeeTea" msgstr "" + +msgid "App Settings" +msgstr "" diff --git a/i18n/en.pot b/i18n/en.pot index ab49a75..40b7f0d 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-30T12:25:50.318Z\n" -"PO-Revision-Date: 2024-12-30T12:25:50.318Z\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 "" @@ -26,19 +29,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 "Loading..." @@ -50,9 +50,6 @@ msgstr "" msgid "Select Organization Units" msgstr "" -msgid "Cancel" -msgstr "" - msgid "Select" msgstr "" @@ -71,6 +68,9 @@ msgstr "" msgid "Templates not found" msgstr "" +msgid "Settings saved" +msgstr "" + msgid "Distributed under GNU GLPv3" msgstr "" @@ -99,3 +99,6 @@ msgstr "" msgid "EyeSeeTea" msgstr "" + +msgid "App Settings" +msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 746b437..8b629ce 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-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" "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" @@ -26,19 +29,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 "Loading..." @@ -50,9 +50,6 @@ msgstr "" msgid "Select Organization Units" msgstr "" -msgid "Cancel" -msgstr "" - msgid "Select" msgstr "" @@ -71,6 +68,9 @@ msgstr "" msgid "Templates not found" msgstr "" +msgid "Settings saved" +msgstr "" + msgid "Distributed under GNU GLPv3" msgstr "" @@ -100,6 +100,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 a0a83c9..12360dd 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-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" "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 "" @@ -26,19 +29,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 "Loading..." @@ -50,9 +50,6 @@ msgstr "" msgid "Select Organization Units" msgstr "" -msgid "Cancel" -msgstr "" - msgid "Select" msgstr "" @@ -71,6 +68,9 @@ msgstr "" msgid "Templates not found" msgstr "" +msgid "Settings saved" +msgstr "" + msgid "Distributed under GNU GLPv3" msgstr "" @@ -99,3 +99,6 @@ msgstr "" msgid "EyeSeeTea" msgstr "" + +msgid "App Settings" +msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index a0a83c9..12360dd 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-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" "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 "" @@ -26,19 +29,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 "Loading..." @@ -50,9 +50,6 @@ msgstr "" msgid "Select Organization Units" msgstr "" -msgid "Cancel" -msgstr "" - msgid "Select" msgstr "" @@ -71,6 +68,9 @@ msgstr "" msgid "Templates not found" msgstr "" +msgid "Settings saved" +msgstr "" + msgid "Distributed under GNU GLPv3" msgstr "" @@ -99,3 +99,6 @@ msgstr "" msgid "EyeSeeTea" msgstr "" + +msgid "App Settings" +msgstr "" diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 5031a33..fb43961 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -18,6 +18,8 @@ import { GetPluginVisualizationUseCase } from "./domain/usecases/GetPluginVisual import { GetRootOrgUnitsUseCase } from "./domain/usecases/GetRootOrgUnitsUseCase"; import { OrgUnitD2Repository } from "./data/repositories/OrgUnitD2Repository"; import { GetOrgUnitsByIdsUseCase } from "./domain/usecases/GetOrgUnitsByIdsUseCase"; +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); @@ -28,6 +30,7 @@ export function getCompositionRoot(api: D2Api, instance: Instance, storageName: const exportDocxRepository = new DashboardExportDocxRepository(); const pluginVisualizationsRepository = new PluginVisualizationD2Repository(api); const orgUnitsRepository = new OrgUnitD2Repository(api); + const defaultSettingsRepository = new DefaultSettingsHTTPRepository(); return { instance: { @@ -45,6 +48,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/app-config.ts b/src/app-config.ts index 548def3..c540d01 100644 --- a/src/app-config.ts +++ b/src/app-config.ts @@ -17,12 +17,27 @@ export const appConfig: AppConfig = { buttonPosition: "bottom-end", }, }, + header: false, + footer: false, }; +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/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..8f99187 --- /dev/null +++ b/src/domain/usecases/InitDefaultSettingsUseCase.ts @@ -0,0 +1,17 @@ +import { 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).map(() => defaultSettings); + }); + } +} diff --git a/src/domain/usecases/SaveSettingsUseCase.ts b/src/domain/usecases/SaveSettingsUseCase.ts index 7b126f4..b710ca6 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/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/components/dashboard-reports/DashboardReports.tsx b/src/webapp/components/dashboard-reports/DashboardReports.tsx index 085770f..0f64765 100644 --- a/src/webapp/components/dashboard-reports/DashboardReports.tsx +++ b/src/webapp/components/dashboard-reports/DashboardReports.tsx @@ -10,14 +10,13 @@ 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"; import { getIdFromPath } from "../../../domain/entities/OrgUnit"; export const DashboardReports: React.FC = React.memo(() => { @@ -25,10 +24,9 @@ export const DashboardReports: React.FC = React.memo(() => { 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 }); @@ -38,26 +36,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); @@ -118,11 +96,15 @@ export const DashboardReports: React.FC = React.memo(() => { )} - - - - - + {isAdmin && ( + + + + + + + + )} @@ -148,15 +130,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..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,9 +18,12 @@ 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"), { + autoHideDuration: 3000, + }); }, err => { snackbar.openSnackbar("error", err); @@ -28,7 +31,7 @@ export function useSettings(template: TemplateReport | undefined) { } ); }, - [loading, compositionRoot, snackbar] + [loading, compositionRoot, snackbar, currentUser] ); return { 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/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..bb31d8c 100644 --- a/src/webapp/pages/app/App.tsx +++ b/src/webapp/pages/app/App.tsx @@ -6,9 +6,9 @@ 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 { 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"; @@ -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; @@ -24,11 +26,17 @@ 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; +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 }) { @@ -38,16 +46,11 @@ 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(); - } + const settings = await getSettingsOrInitialize(compositionRoot); if (!currentUser) throw new Error("User not logged in"); setAppContext({ api, currentUser, compositionRoot, isDev, settings, setAppContext, pluginVersion }); @@ -63,8 +66,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 +77,7 @@ export const App: React.FC = React.memo(function App({ api, d2, instan + {appConfig.footer && } 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; +`;