From 58a24dbb361758c97070dfd514ae810d1bc277b7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 30 May 2024 14:35:01 +0300 Subject: [PATCH] Adding Reports Dashboard --- packages/esm-reports-app/README.md | 10 + packages/esm-reports-app/jest.config.js | 2 +- packages/esm-reports-app/package.json | 48 +-- .../edit-scheduled-report-form.component.tsx | 153 ++++++++ .../edit-scheduled-report-form.scss | 60 +++ .../next-report-execution.component.tsx | 28 ++ .../src/components/overlay.component.tsx | 40 ++ .../src/components/overlay.scss | 95 +++++ .../src/components/overview.component.tsx | 346 ++++++++++++++++++ .../src/components/pagination-constants.ts | 3 + .../report-overview-button.component.tsx | 27 ++ .../report-parameter-input.component.tsx | 122 ++++++ .../report-schedule-description.component.tsx | 21 ++ .../components/report-status.component.tsx | 68 ++++ .../components/report-statuses-constants.ts | 7 + .../src/components/reports.resource.tsx | 239 ++++++++++++ .../src/components/reports.scss | 90 +++++ .../cancel-report-modal.component.tsx | 123 +++++++ .../run-report/run-report-form.component.tsx | 244 ++++++++++++ .../run-report/run-report-form.scss | 87 +++++ ...eduled-overview-cell-content.component.tsx | 85 +++++ .../scheduled-overview.component.tsx | 109 ++++++ .../scheduled-report-status.component.tsx | 23 ++ .../components/simple-cron-editor/commons.tsx | 27 ++ .../cron-date-picker.component.tsx | 67 ++++ .../cron-day-of-month-select.component.tsx | 66 ++++ .../cron-day-of-week-select.component.tsx | 65 ++++ .../cron-time-picker.component.tsx | 63 ++++ .../simple-cron-editor.component.tsx | 333 +++++++++++++++++ .../simple-cron-editor.scss | 36 ++ packages/esm-reports-app/src/config-schema.ts | 24 +- packages/esm-reports-app/src/constants.ts | 11 +- .../src/createDashboardLink.component.tsx | 40 +- .../esm-reports-app/src/dashboard.meta.ts | 5 +- .../esm-reports-app/src/declarations.d.ts | 11 +- .../esm-reports-app/src/hooks/useOverlay.tsx | 45 +++ packages/esm-reports-app/src/index.ts | 97 ++++- packages/esm-reports-app/src/offline.ts | 1 - packages/esm-reports-app/src/reports-link.tsx | 22 ++ .../esm-reports-app/src/reports.component.tsx | 24 ++ .../src/resources/resources.component.tsx | 80 ++++ .../src/resources/resources.scss | 62 ++++ .../esm-reports-app/src/root.component.tsx | 8 +- packages/esm-reports-app/src/root.scss | 15 + packages/esm-reports-app/src/routes.json | 39 +- packages/esm-reports-app/src/setup-tests.ts | 1 + .../src/types/report-definition.ts | 12 + .../src/types/report-design.ts | 4 + .../src/types/report-request.ts | 8 + .../esm-reports-app/src/utils/date-utils.tsx | 5 + .../src/utils/openmrs-cron-utiils.tsx | 26 ++ .../esm-reports-app/src/utils/time-utils.tsx | 26 ++ packages/esm-reports-app/translations/ar.json | 76 +++- packages/esm-reports-app/translations/en.json | 76 +++- packages/esm-reports-app/tsconfig.json | 2 +- yarn.lock | 68 +++- 56 files changed, 3383 insertions(+), 92 deletions(-) create mode 100644 packages/esm-reports-app/README.md create mode 100644 packages/esm-reports-app/src/components/edit-scheduled-report/edit-scheduled-report-form.component.tsx create mode 100644 packages/esm-reports-app/src/components/edit-scheduled-report/edit-scheduled-report-form.scss create mode 100644 packages/esm-reports-app/src/components/next-report-execution.component.tsx create mode 100644 packages/esm-reports-app/src/components/overlay.component.tsx create mode 100644 packages/esm-reports-app/src/components/overlay.scss create mode 100644 packages/esm-reports-app/src/components/overview.component.tsx create mode 100644 packages/esm-reports-app/src/components/pagination-constants.ts create mode 100644 packages/esm-reports-app/src/components/report-overview-button.component.tsx create mode 100644 packages/esm-reports-app/src/components/report-parameter-input.component.tsx create mode 100644 packages/esm-reports-app/src/components/report-schedule-description.component.tsx create mode 100644 packages/esm-reports-app/src/components/report-status.component.tsx create mode 100644 packages/esm-reports-app/src/components/report-statuses-constants.ts create mode 100644 packages/esm-reports-app/src/components/reports.resource.tsx create mode 100644 packages/esm-reports-app/src/components/reports.scss create mode 100644 packages/esm-reports-app/src/components/run-report/cancel-report-modal.component.tsx create mode 100644 packages/esm-reports-app/src/components/run-report/run-report-form.component.tsx create mode 100644 packages/esm-reports-app/src/components/run-report/run-report-form.scss create mode 100644 packages/esm-reports-app/src/components/scheduled-overview-cell-content.component.tsx create mode 100644 packages/esm-reports-app/src/components/scheduled-overview.component.tsx create mode 100644 packages/esm-reports-app/src/components/scheduled-report-status.component.tsx create mode 100644 packages/esm-reports-app/src/components/simple-cron-editor/commons.tsx create mode 100644 packages/esm-reports-app/src/components/simple-cron-editor/cron-date-picker.component.tsx create mode 100644 packages/esm-reports-app/src/components/simple-cron-editor/cron-day-of-month-select.component.tsx create mode 100644 packages/esm-reports-app/src/components/simple-cron-editor/cron-day-of-week-select.component.tsx create mode 100644 packages/esm-reports-app/src/components/simple-cron-editor/cron-time-picker.component.tsx create mode 100644 packages/esm-reports-app/src/components/simple-cron-editor/simple-cron-editor.component.tsx create mode 100644 packages/esm-reports-app/src/components/simple-cron-editor/simple-cron-editor.scss create mode 100644 packages/esm-reports-app/src/hooks/useOverlay.tsx create mode 100644 packages/esm-reports-app/src/reports-link.tsx create mode 100644 packages/esm-reports-app/src/reports.component.tsx create mode 100644 packages/esm-reports-app/src/resources/resources.component.tsx create mode 100644 packages/esm-reports-app/src/resources/resources.scss create mode 100644 packages/esm-reports-app/src/root.scss create mode 100644 packages/esm-reports-app/src/setup-tests.ts create mode 100644 packages/esm-reports-app/src/types/report-definition.ts create mode 100644 packages/esm-reports-app/src/types/report-design.ts create mode 100644 packages/esm-reports-app/src/types/report-request.ts create mode 100644 packages/esm-reports-app/src/utils/date-utils.tsx create mode 100644 packages/esm-reports-app/src/utils/openmrs-cron-utiils.tsx create mode 100644 packages/esm-reports-app/src/utils/time-utils.tsx diff --git a/packages/esm-reports-app/README.md b/packages/esm-reports-app/README.md new file mode 100644 index 0000000..40e7a01 --- /dev/null +++ b/packages/esm-reports-app/README.md @@ -0,0 +1,10 @@ +![Node.js CI](https://github.com/openmrs/openmrs-esm-template-app/workflows/Node.js%20CI/badge.svg) + +# Reports Module + +The `openmrs-esm-reports-app` is a package which provides Report admin pages: +- An overview of Report execution history included currently queued reports, with possibilities to execute specific report +, preserve, download and delete completed execution +- An overview of an execution schedule with possibilities to view, edit and delete a schedule + +The pages are available in the app's main menu under Reports entry. diff --git a/packages/esm-reports-app/jest.config.js b/packages/esm-reports-app/jest.config.js index 72e2518..0352f62 100644 --- a/packages/esm-reports-app/jest.config.js +++ b/packages/esm-reports-app/jest.config.js @@ -1,3 +1,3 @@ -const rootConfig = require("../../jest.config.js"); +const rootConfig = require('../../jest.config.js'); module.exports = rootConfig; diff --git a/packages/esm-reports-app/package.json b/packages/esm-reports-app/package.json index a092a7d..60f8cc2 100644 --- a/packages/esm-reports-app/package.json +++ b/packages/esm-reports-app/package.json @@ -1,55 +1,57 @@ { "name": "@sjthc/esm-reports-app", - "version": "1.0.0", - "description": "Microfrontend for managing reports O3", + "version": "1.0.1", + "license": "MPL-2.0", + "description": "Reports Admin page for OpenMRS", "browser": "dist/sjthc-esm-reports-app.js", "main": "src/index.ts", "source": true, - "license": "MPL-2.0", - "homepage": "", "scripts": { "start": "openmrs develop", "serve": "webpack serve --mode=development", - "debug": "npm run serve", "build": "webpack --mode production", "analyze": "webpack --mode=production --env.analyze=true", - "lint": "cross-env eslint src --ext ts,tsx", + "lint": "eslint src --ext js,jsx,ts,tsx", + "typescript": "tsc", "test": "cross-env TZ=UTC jest --config jest.config.js --verbose false --passWithNoTests --color", "test:watch": "cross-env TZ=UTC jest --watch --config jest.config.js --color", - "coverage": "yarn test --coverage", - "typescript": "tsc", "extract-translations": "i18next 'src/**/*.component.tsx' --config ../../tools/i18next-parser.config.js" }, "browserslist": [ "extends browserslist-config-openmrs" ], "keywords": [ - "openmrs" + "openmrs", + "microfrontends", + "reports" ], - "publishConfig": { - "access": "public" - }, "repository": { "type": "git", - "url": "git+" + "url": "git+https://github.com/openmrs/openmrs-esm-admin-tools.git" + }, + "homepage": "https://github.com/openmrs/openmrs-esm-admin-tools#readme", + "publishConfig": { + "access": "public" }, "bugs": { - "url": "" + "url": "https://github.com/openmrs/openmrs-esm-admin-tools/issues" }, "dependencies": { - "@carbon/react": "~1.37.0", - "dexie": "^3.0.3", - "fuzzy": "^0.1.3", - "lodash-es": "^4.17.15" + "@carbon/react": "^1.33.1", + "@datasert/cronjs-matcher": "^1.2.0", + "@datasert/cronjs-parser": "^1.2.0", + "cronstrue": "^2.41.0", + "dayjs": "^1.8.36", + "lodash-es": "^4.17.21", + "react-image-annotate": "^1.8.0" }, "peerDependencies": { - "@openmrs/esm-framework": "5.x", + "@openmrs/esm-framework": "*", + "dayjs": "1.x", "react": "18.x", "react-i18next": "11.x", "react-router-dom": "6.x", - "swr": "2.x" + "rxjs": "6.x" }, - "devDependencies": { - "webpack": "^5.74.0" - } + "packageManager": "yarn@3.6.1" } diff --git a/packages/esm-reports-app/src/components/edit-scheduled-report/edit-scheduled-report-form.component.tsx b/packages/esm-reports-app/src/components/edit-scheduled-report/edit-scheduled-report-form.component.tsx new file mode 100644 index 0000000..b5e9796 --- /dev/null +++ b/packages/esm-reports-app/src/components/edit-scheduled-report/edit-scheduled-report-form.component.tsx @@ -0,0 +1,153 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { first } from 'rxjs/operators'; +import styles from './edit-scheduled-report-form.scss'; +import SimpleCronEditor from '../simple-cron-editor/simple-cron-editor.component'; +import { + useReportDefinition, + useReportDesigns, + useReportRequest, + runReportObservable, + RunReportRequest, +} from '../reports.resource'; +import ReportParameterInput from '../report-parameter-input.component'; +import { Button, ButtonSet, Form, Select, SelectItem, Stack } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { showToast, useLayoutType } from '@openmrs/esm-framework'; + +interface EditScheduledReportForm { + reportDefinitionUuid: string; + reportRequestUuid: string; + closePanel: () => void; +} + +const EditScheduledReportForm: React.FC = ({ + reportDefinitionUuid, + reportRequestUuid, + closePanel, +}) => { + const { t } = useTranslation(); + const isTablet = useLayoutType() === 'tablet'; + + const reportDefinition = useReportDefinition(reportDefinitionUuid); + const { reportDesigns } = useReportDesigns(reportDefinitionUuid); + const { reportRequest } = useReportRequest(reportRequestUuid); + + const [reportParameters, setReportParameters] = useState({}); + const [renderModeUuid, setRenderModeUuid] = useState(); + const [initialCron, setInitialCron] = useState(); + const [schedule, setSchedule] = useState(); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSubmittable, setIsSubmittable] = useState(false); + const [ignoreChanges, setIgnoreChanges] = useState(true); + + useEffect(() => { + setInitialCron(reportRequest?.schedule); + setRenderModeUuid(reportRequest?.renderingMode?.argument); + }, [reportRequest]); + + const handleSubmit = useCallback( + (event) => { + event.preventDefault(); + + setIsSubmitting(true); + + const runReportRequest: RunReportRequest = { + existingRequestUuid: reportRequestUuid, + reportDefinitionUuid, + renderModeUuid, + reportParameters, + schedule, + }; + + const abortController = new AbortController(); + runReportObservable(runReportRequest, abortController) + .pipe(first()) + .subscribe( + () => { + showToast({ + critical: true, + kind: 'success', + title: t('reportScheduled', 'Report scheduled'), + description: t('reportScheduledSuccessfullyMsg', 'Report scheduled successfully'), + }); + closePanel(); + setIsSubmitting(false); + }, + (error) => { + console.error(error); + showToast({ + critical: true, + kind: 'error', + title: t('reportScheduledErrorMsg', 'Failed to schedule a report'), + description: t('reportScheduledErrorMsg', 'Failed to schedule a report'), + }); + closePanel(); + setIsSubmitting(false); + }, + ); + }, + [closePanel, renderModeUuid, reportRequestUuid, reportRequestUuid, reportParameters, schedule], + ); + + const handleOnChange = () => { + setIgnoreChanges((prevState) => !prevState); + }; + + const handleCronEditorChange = (cron: string, isValid: boolean) => { + setSchedule(isValid ? cron : null); + }; + + useEffect(() => { + setIsSubmittable(!!schedule && !!renderModeUuid); + }, [schedule, renderModeUuid]); + + return ( +
+ + + {reportDefinition && + reportDefinition.parameters.map((parameter) => ( + { + setReportParameters((state) => ({ + ...state, + [parameter.name]: parameterValue, + })); + }} + /> + ))} +
+ +
+
+
+ + + + +
+
+ ); +}; + +export default EditScheduledReportForm; diff --git a/packages/esm-reports-app/src/components/edit-scheduled-report/edit-scheduled-report-form.scss b/packages/esm-reports-app/src/components/edit-scheduled-report/edit-scheduled-report-form.scss new file mode 100644 index 0000000..5652526 --- /dev/null +++ b/packages/esm-reports-app/src/components/edit-scheduled-report/edit-scheduled-report-form.scss @@ -0,0 +1,60 @@ +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/type'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.tablet { + padding: spacing.$spacing-06 spacing.$spacing-05; + background-color: $ui-02; +} + +.desktop { + padding: 0rem; +} + +.button { + height: 4rem; + display: flex; + align-content: flex-start; + align-items: baseline; + min-width: 50%; +} + +.container { + margin: spacing.$spacing-05 0rem; + background-color: $ui-background; + + & section { + margin: spacing.$spacing-02 spacing.$spacing-05 0; + } +} + +.desktopEditSchedule { + background-color: $ui-background; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.outputFormatDiv { + margin-bottom: 50px; + display: flex; + padding: 32px 16px 16px 16px; + flex-direction: column; + align-items: flex-start; + gap: 16px; +} + +.basicInputElement { + width: 300px; + height: 30px; + margin-bottom: 30px; +} + +.buttonsDiv { + margin-top: 50px; +} + +.reportButton { + max-width: none !important; + width: 350px !important; +} diff --git a/packages/esm-reports-app/src/components/next-report-execution.component.tsx b/packages/esm-reports-app/src/components/next-report-execution.component.tsx new file mode 100644 index 0000000..5d98917 --- /dev/null +++ b/packages/esm-reports-app/src/components/next-report-execution.component.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import * as cronjsParser from '@datasert/cronjs-parser'; +import * as cronjsMatcher from '@datasert/cronjs-matcher'; +import moment from 'moment'; + +interface NextReportExecutionProps { + schedule: string; + currentDate: Date; +} + +const NextReportExecution: React.FC = ({ schedule, currentDate }) => { + const nextReportExecutionDate = (() => { + if (!schedule) { + return ''; + } + + const expression = cronjsParser.parse(schedule, { hasSeconds: true }); + const nextExecutions = cronjsMatcher.getFutureMatches(expression, { + startAt: currentDate.toISOString(), + matchCount: 1, + }); + return nextExecutions.length == 1 ? moment.utc(nextExecutions[0].toString()).format('YYYY-MM-DD HH:mm') : ''; + })(); + + return {nextReportExecutionDate}; +}; + +export default NextReportExecution; diff --git a/packages/esm-reports-app/src/components/overlay.component.tsx b/packages/esm-reports-app/src/components/overlay.component.tsx new file mode 100644 index 0000000..43eb4be --- /dev/null +++ b/packages/esm-reports-app/src/components/overlay.component.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Button, Header } from '@carbon/react'; +import { ArrowLeft, Close } from '@carbon/react/icons'; +import { useLayoutType } from '@openmrs/esm-framework'; +import { closeOverlay, useOverlay } from '../hooks/useOverlay'; +import styles from './overlay.scss'; + +const Overlay: React.FC = () => { + const { header, component, isOverlayOpen } = useOverlay(); + const layout = useLayoutType(); + const overlayClass = layout !== 'tablet' ? styles.desktopOverlay : styles.tabletOverlay; + return ( + <> + {isOverlayOpen && ( +
+ {layout === 'tablet' && ( +
closeOverlay()} aria-label="Tablet overlay" className={styles.tabletOverlayHeader}> + +
{header}
+
+ )} + + {layout !== 'tablet' && ( +
+
{header}
+ +
+ )} + {component} +
+ )} + + ); +}; + +export default Overlay; diff --git a/packages/esm-reports-app/src/components/overlay.scss b/packages/esm-reports-app/src/components/overlay.scss new file mode 100644 index 0000000..d85edb7 --- /dev/null +++ b/packages/esm-reports-app/src/components/overlay.scss @@ -0,0 +1,95 @@ +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/type'; +@import '~@openmrs/esm-styleguide/src/vars'; +@import './../root.scss'; + +.desktopOverlay { + position: fixed; + top: spacing.$spacing-09; + width: 700px; + right: 0; + bottom: 0; + border-left: 1px solid $text-03; + background-color: $ui-01; + overflow-y: auto; + height: calc(100vh - 3rem); + z-index: 3; +} + +.desktopOverlay::after { + height: 100%; + border-left: 1px solid $text-03; +} + +.tabletOverlay { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 9999; + background-color: $ui-01; + overflow-y: scroll; + -ms-overflow-style: none; + scrollbar-width: none; + z-index: 3; + + &::-webkit-scrollbar { + width: 0; + } + + & > div { + margin-top: spacing.$spacing-09; + } +} + +.tabletOverlayHeader { + button { + @include brand-01(background-color); + } + + .headerContent { + color: $ui-02; + } +} + +.desktopHeader { + display: flex; + justify-content: space-between; + align-items: center; + background-color: $ui-03; + border-bottom: 1px solid $text-03; + position: sticky; + position: -webkit-sticky; + width: 100%; + z-index: 1000; + top: 0; +} + +.headerContent { + @include type.type-style('heading-compact-02'); + padding: 0 spacing.$spacing-05; + color: $ui-05; +} + +.closeButton { + background-color: $ui-background; + color: $ui-05; + fill: $ui-05; +} + +/* Desktop */ +:global(.omrs-breakpoint-gt-tablet) { + .overlayContent { + padding: 0 0 0 0; + overflow-y: auto; + } +} + +/* Tablet */ +:global(.omrs-breakpoint-lt-desktop) { + .overlayContent { + padding: 0 0 0 0; + overflow-y: auto; + } +} \ No newline at end of file diff --git a/packages/esm-reports-app/src/components/overview.component.tsx b/packages/esm-reports-app/src/components/overview.component.tsx new file mode 100644 index 0000000..86eae9c --- /dev/null +++ b/packages/esm-reports-app/src/components/overview.component.tsx @@ -0,0 +1,346 @@ +import { + Button, + Checkbox, + DataTable, + Pagination, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from '@carbon/react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { downloadMultipleReports, downloadReport, preserveReport, useReports } from './reports.resource'; +import { + ExtensionSlot, + isDesktop, + navigate, + showModal, + showToast, + useLayoutType, + userHasAccess, + useSession, +} from '@openmrs/esm-framework'; +import { Calendar, Download, Play, Save, TrashCan } from '@carbon/react/icons'; +import styles from './reports.scss'; +import { DEFAULT_PAGE_NUMBER, DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZES } from './pagination-constants'; +import { closeOverlay, launchOverlay } from '../hooks/useOverlay'; +import RunReportForm from './run-report/run-report-form.component'; +import Overlay from './overlay.component'; +import ReportStatus from './report-status.component'; +import { COMPLETED, SAVED } from './report-statuses-constants'; +import ReportOverviewButton from './report-overview-button.component'; +import { PRIVILEGE_SYSTEM_DEVELOPER } from '../constants'; + +const OverviewComponent: React.FC = () => { + const { t } = useTranslation(); + const currentSession = useSession(); + + let [checkedReportUuidsArray, setCheckedReportUuidsArray] = useState([]); + const [downloadReportButtonVisible, setDownloadReportButtonVisible] = useState(false); + + useEffect(() => { + setDownloadReportButtonVisible(checkedReportUuidsArray.length > 0); + }, [checkedReportUuidsArray]); + + const tableHeaders = [ + { key: 'reportName', header: t('reportName', 'Report Name') }, + { key: 'status', header: t('status', 'Status') }, + { key: 'requestedBy', header: t('requestedBy', 'Requested by') }, + { key: 'requestedOn', header: t('requestedOn', 'Requested on') }, + { key: 'outputFormat', header: t('outputFormat', 'Output format') }, + { key: 'parameters', header: t('parameters', 'Parameters') }, + { key: 'actions', header: t('actions', 'Actions') }, + ]; + + const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE_NUMBER); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + + const { reports, reportsTotalCount, mutateReports } = useReports('ran', currentPage, pageSize); + + const layout = useLayoutType(); + + function getReportStatus(row) { + return row?.cells.find((cell) => cell.info?.header === 'status')?.value; + } + + function isCurrentUserTheSameAsReportRequestedByUser(reportRequestUuid: string) { + const report = reports.find((tableRow) => tableRow.id === reportRequestUuid); + const requestedByUserUuid = report?.requestedByUserUuid; + const currentUserUuid = currentSession?.user.uuid; + + return requestedByUserUuid === currentUserUuid; + } + + function isSystemDeveloperUser() { + return userHasAccess(PRIVILEGE_SYSTEM_DEVELOPER, currentSession.user); + } + + function isEligibleReportUser(reportRequestUuid: string) { + return isCurrentUserTheSameAsReportRequestedByUser(reportRequestUuid) || isSystemDeveloperUser(); + } + + function renderRowCheckbox(row, index) { + const statusCell = row?.cells.find((cell) => cell.info?.header === 'status'); + const statusValue = statusCell?.value; + if (statusValue === COMPLETED || statusValue === SAVED) { + return ( + + handleOnCheckboxClick(e)} + checked={checkedReportUuidsArray.includes(row.id)} + /> + + ); + } else { + return ; + } + } + + function handleOnCheckboxClick(event) { + const checkboxElement = event?.target; + const checkboxId = checkboxElement.id; + const reportUuid = checkboxId.slice(checkboxId.indexOf('-') + 1); + const isChecked = checkboxElement?.checked; + + setCheckedReportUuidsArray((prevState) => { + if (isChecked && !prevState.includes(reportUuid)) { + return [...prevState, reportUuid]; + } else { + return prevState.filter((checkedReportUuid) => checkedReportUuid !== reportUuid); + } + }); + } + + const handlePreserveReport = useCallback(async (reportRequestUuid: string) => { + preserveReport(reportRequestUuid) + .then(() => { + mutateReports(); + showToast({ + critical: true, + kind: 'success', + title: t('preserveReport', 'Preserve report'), + description: t('reportPreservedSuccessfully', 'Report preserved successfully'), + }); + }) + .catch((error) => { + showToast({ + critical: true, + kind: 'error', + title: t('preserveReport', 'Preserve report'), + description: t('reportPreservingErrorMsg', 'Error during report preserving'), + }); + }); + }, []); + + const launchDeleteReportDialog = (reportRequestUuid: string) => { + const dispose = showModal('cancel-report-modal', { + closeModal: () => { + dispose(); + mutateReports(); + }, + reportRequestUuid, + modalType: 'delete', + }); + }; + + const handleDownloadReport = useCallback(async (reportRequestUuid: string) => { + try { + const response = await downloadReport(reportRequestUuid); + processAndDownloadFile(response); + clearReportCheckboxes(); + showToast({ + critical: true, + kind: 'success', + title: t('downloadReport', 'Download report'), + description: t('reportDownloadedSuccessfully', 'Report downloaded successfully'), + }); + } catch (error) { + showToast({ + critical: true, + kind: 'error', + title: t('downloadReport', 'Download report'), + description: t('reportDownloadingErrorMsg', 'Error during report downloading'), + }); + } + }, []); + + const handleDownloadMultipleReports = useCallback(async (reportRequestUuids) => { + try { + const response = await downloadMultipleReports(reportRequestUuids); + response.forEach((file) => processAndDownloadFile(file)); + clearReportCheckboxes(); + showToast({ + critical: true, + kind: 'success', + title: t('downloadReport', 'Download report(s)'), + description: t('reportDownloadedSuccessfully', 'Report(s) downloaded successfully'), + }); + } catch (error) { + showToast({ + critical: true, + kind: 'error', + title: t('downloadReport', 'Download report(s)s'), + description: t('reportDownloadingErrorMsg', 'Error during report(s) downloading'), + }); + } + }, []); + + const processAndDownloadFile = (file) => { + const decodedData = window.atob(file.fileContent); + const byteArray = new Uint8Array(decodedData.length); + for (let i = 0; i < decodedData.length; i++) { + byteArray[i] = decodedData.charCodeAt(i); + } + const url = window.URL.createObjectURL(new Blob([byteArray])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', file.filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const clearReportCheckboxes = () => { + setCheckedReportUuidsArray([]); + }; + + return ( +
+ +
+
+

{t('reports', 'Reports')}

+
+
+ + + + +
+
+ + {({ rows, headers }) => ( + + + + + + {headers.map((header) => ( + {header.header?.content ?? header.header} + ))} + + + + {rows.map((row, index) => ( + + {renderRowCheckbox(row, index)} + {row.cells.map((cell) => ( + + {cell.info.header === 'actions' ? ( +
+ } + reportRequestUuid={row.id} + onClick={() => handleDownloadReport(row.id)} + /> + } + reportRequestUuid={row.id} + onClick={() => handlePreserveReport(row.id)} + /> + } + reportRequestUuid={row.id} + onClick={() => launchDeleteReportDialog(row.id)} + /> +
+ ) : cell.info.header === 'status' ? ( +
+ +
+ ) : cell.info.header === 'reportName' ? ( +
{cell.value?.content ?? cell.value}
+ ) : ( + cell.value?.content ?? cell.value + )} +
+ ))} +
+ ))} +
+
+
+ )} +
+ {reports.length > 0 ? ( + { + if (newPageSize !== pageSize) { + setPageSize(newPageSize); + } + + if (newPage !== currentPage) { + setCurrentPage(newPage); + } + }} + /> + ) : null} +
+ ); +}; + +export default OverviewComponent; diff --git a/packages/esm-reports-app/src/components/pagination-constants.ts b/packages/esm-reports-app/src/components/pagination-constants.ts new file mode 100644 index 0000000..83e5ca5 --- /dev/null +++ b/packages/esm-reports-app/src/components/pagination-constants.ts @@ -0,0 +1,3 @@ +export const DEFAULT_PAGE_SIZE = 10; +export const DEFAULT_PAGE_SIZES = [5, 10, 20, 50]; +export const DEFAULT_PAGE_NUMBER = 1; diff --git a/packages/esm-reports-app/src/components/report-overview-button.component.tsx b/packages/esm-reports-app/src/components/report-overview-button.component.tsx new file mode 100644 index 0000000..5eca296 --- /dev/null +++ b/packages/esm-reports-app/src/components/report-overview-button.component.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Button } from '@carbon/react'; +import styles from './reports.scss'; + +interface ReportOverviewButtonProps { + shouldBeDisplayed: boolean; + label: string; + icon: any; + reportRequestUuid: string; + onClick: () => void; +} + +const ReportOverviewButton: React.FC = ({ shouldBeDisplayed, label, icon, onClick }) => { + if (shouldBeDisplayed) { + return ( +
+ +
+ ); + } else { + return
; + } +}; + +export default ReportOverviewButton; diff --git a/packages/esm-reports-app/src/components/report-parameter-input.component.tsx b/packages/esm-reports-app/src/components/report-parameter-input.component.tsx new file mode 100644 index 0000000..dac3b46 --- /dev/null +++ b/packages/esm-reports-app/src/components/report-parameter-input.component.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useState } from 'react'; +import styles from './run-report/run-report-form.scss'; +import { DatePicker, DatePickerInput, Select, SelectItem, TextInput } from '@carbon/react'; +import { useLocations } from './reports.resource'; +import { isEqual } from 'lodash-es'; + +interface ReportParameterInputProps { + parameter: any; + value: any; + onChange: (value) => void; +} + +function getInitialValue(parameter: any, value: any) { + if (parameter.type === 'java.util.Date') { + return new Date(value); + } else if (parameter.type === 'org.openmrs.Location') { + return value?.uuid; + } else { + return value; + } +} + +const ReportParameterInput: React.FC = ({ parameter, value, onChange }) => { + const { locations } = useLocations(); + const [valueInternal, setValueInternal] = useState(getInitialValue(parameter, value)); + + useEffect(() => { + const newInternalValue = getInitialValue(parameter, value); + if (!isValueEqual(newInternalValue, valueInternal)) { + setValueInternal(newInternalValue); + } + }, [value]); + + const isValueEqual = (valueA, valueB) => { + if (parameter.type === 'java.util.Date') { + return isEqual(new Date(valueA), new Date(valueB)); + } else { + return isEqual(valueA, valueB); + } + }; + + useEffect(() => { + if (parameter.type === 'java.util.Date') { + onChange(new Date(valueInternal).toLocaleDateString()); + } else { + onChange(valueInternal); + } + }, [valueInternal]); + + const renderParameterElementBasedOnType = () => { + switch (parameter.type) { + case 'java.util.Date': + return ( + handleOnDateChange(dateValue)} + className={styles.datePicker} + value={valueInternal} + > + + + ); + case 'java.lang.String': + case 'java.lang.Integer': + return ( + handleOnChange(e)} + value={valueInternal} + /> + ); + case 'org.openmrs.Location': + return ( + + ); + default: + return ( + + {`Unknown parameter type: ${parameter.type} for parameter: ${parameter.label}`} + + ); + } + }; + + function handleOnChange(event) { + let eventValue = null; + if (event.target.type == 'checkbox') { + eventValue = event.target.checked; + } else { + eventValue = event.target.value; + } + + setValueInternal(eventValue); + } + + function handleOnDateChange(dateValue) { + setValueInternal(new Date(dateValue)); + } + + return
{renderParameterElementBasedOnType()}
; +}; + +export default ReportParameterInput; diff --git a/packages/esm-reports-app/src/components/report-schedule-description.component.tsx b/packages/esm-reports-app/src/components/report-schedule-description.component.tsx new file mode 100644 index 0000000..1c2bad2 --- /dev/null +++ b/packages/esm-reports-app/src/components/report-schedule-description.component.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { useSession } from '@openmrs/esm-framework'; +import cronstrue from 'cronstrue'; + +interface ReportScheduleProps { + schedule: string; +} + +const ReportScheduleDescription: React.FC = ({ schedule }) => { + const session = useSession(); + const scheduleDescription = schedule + ? cronstrue.toString(schedule, { + locale: session.locale, + use24HourTimeFormat: true, + dayOfWeekStartIndexZero: false, + }) + : ''; + return {scheduleDescription}; +}; + +export default ReportScheduleDescription; diff --git a/packages/esm-reports-app/src/components/report-status.component.tsx b/packages/esm-reports-app/src/components/report-status.component.tsx new file mode 100644 index 0000000..d4e9906 --- /dev/null +++ b/packages/esm-reports-app/src/components/report-status.component.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { CheckmarkFilled, CheckmarkOutline, CloseFilled, Queued } from '@carbon/react/icons'; +import { Loading } from '@carbon/react'; +import styles from './reports.scss'; +import { + COMPLETED, + FAILED, + PROCESSING, + SAVED, + SCHEDULED, + SCHEDULE_COMPLETED, + REQUESTED, +} from './report-statuses-constants'; + +interface ReportStatusProps { + status: string; +} + +const ReportStatus: React.FC = ({ status }) => { + const { t } = useTranslation(); + return ( + <> + {status === SAVED && ( + <> + + {t('completedAndPreserved', 'Completed and Preserved')} + + )} + {status === COMPLETED && ( + <> + + {t('completed', 'Completed')} + + )} + {status === PROCESSING && ( + <> + + {t('running', 'Running')} + + )} + {status === FAILED && ( + <> + + {t('failed', 'Failed')} + + )} + {status === SCHEDULED && ( + <> + {t('scheduled', 'Scheduled')} + + )} + {status === SCHEDULE_COMPLETED && ( + <> + {t('scheduleCompleted', 'Schedule completed')} + + )} + {status === REQUESTED && ( + <> + + {t('queued', 'Queued')} + + )} + + ); +}; + +export default ReportStatus; diff --git a/packages/esm-reports-app/src/components/report-statuses-constants.ts b/packages/esm-reports-app/src/components/report-statuses-constants.ts new file mode 100644 index 0000000..196caf1 --- /dev/null +++ b/packages/esm-reports-app/src/components/report-statuses-constants.ts @@ -0,0 +1,7 @@ +export const SAVED = 'SAVED'; +export const COMPLETED = 'COMPLETED'; +export const PROCESSING = 'PROCESSING'; +export const FAILED = 'FAILED'; +export const SCHEDULED = 'SCHEDULED'; +export const SCHEDULE_COMPLETED = 'SCHEDULE_COMPLETED'; +export const REQUESTED = 'REQUESTED'; diff --git a/packages/esm-reports-app/src/components/reports.resource.tsx b/packages/esm-reports-app/src/components/reports.resource.tsx new file mode 100644 index 0000000..1634130 --- /dev/null +++ b/packages/esm-reports-app/src/components/reports.resource.tsx @@ -0,0 +1,239 @@ +import { openmrsFetch, Session, FetchResponse, openmrsObservableFetch } from '@openmrs/esm-framework'; +import { Observable } from 'rxjs'; +import useSWR from 'swr'; +import moment from 'moment'; +import { ReportDefinition } from '../types/report-definition'; +import { ReportDesign } from '../types/report-design'; +import { ReportRequest } from '../types/report-request'; + +interface ReportModel { + reportName: string; + status: string; + requestedBy: string; + requestedByUserUuid: string; + requestedOn: string; + outputFormat: string; + parameters: any; + id: string; + evaluateCompleteDatetime: string; + schedule: string; +} + +interface ScheduledReportModel { + reportDefinitionUuid: string; + reportRequestUuid: string; + name: string; + schedule: string; +} + +export interface RunReportRequest { + existingRequestUuid?: string | undefined; + reportDefinitionUuid: string; + renderModeUuid: string; + reportParameters: any; + schedule?: string | undefined; +} + +export function useLocations() { + const apiUrl = `/ws/rest/v1/location?tag=Login+Location`; + + const { data } = useSWR<{ data: { results: Array } }, Error>(apiUrl, openmrsFetch); + + return { + locations: data ? data?.data?.results : [], + }; +} + +export function useReports(statusesGroup: string, pageNumber: number, pageSize: number, sortBy?: string): any { + const reportsUrl = + `/ws/rest/v1/reportingrest/reportRequest?statusesGroup=${statusesGroup}&startIndex=${pageNumber}&limit=${pageSize}&totalCount=true` + + (sortBy ? `&sortBy=${sortBy}` : ''); + + const { data, error, isValidating, mutate } = useSWR<{ data: { results: Array; totalCount: number } }, Error>( + reportsUrl, + openmrsFetch, + ); + + const reports = data?.data?.results; + const totalCount = data?.data?.totalCount; + const reportsArray: Array = reports ? [].concat(...reports.map((report) => mapReportResults(report))) : []; + + return { + reports: reportsArray, + reportsTotalCount: totalCount, + isError: error, + isValidating: isValidating, + mutateReports: mutate, + }; +} + +export function useReportRequest(reportRequestUuid: string): any { + const reportsUrl = `/ws/rest/v1/reportingrest/reportRequest/${reportRequestUuid}`; + + const { data, error, isValidating, mutate } = useSWR<{ data: ReportRequest }, Error>(reportsUrl, openmrsFetch); + + return { + reportRequest: data?.data, + isError: error, + isValidating: isValidating, + mutate, + }; +} + +export function useScheduledReports(sortBy?: string): any { + const scheduledReportsUrl = `/ws/rest/v1/reportingrest/scheduledReport` + (sortBy ? `?sortBy=${sortBy}` : ''); + + const { data, error, isValidating, mutate } = useSWR<{ data: { results: Array } }, Error>( + scheduledReportsUrl, + openmrsFetch, + ); + + const scheduledReports = data?.data?.results; + const scheduledReportsArray: Array = scheduledReports + ? [].concat(...scheduledReports.map((report) => mapScheduledReportResults(report))) + : []; + + return { + scheduledReports: scheduledReportsArray, + isError: error, + isValidating: isValidating, + mutateScheduledReports: mutate, + }; +} + +export function useReportDefinitions() { + const apiUrl = `/ws/rest/v1/reportingrest/reportDefinition?v=full`; + + const { data } = useSWR<{ data: { results: Array } }, Error>(apiUrl, openmrsFetch); + + return { + reportDefinitions: data ? data?.data?.results : [], + }; +} + +export function useReportDefinition(reportDefinitionUuid: string): ReportDefinition { + const apiUrl = `/ws/rest/v1/reportingrest/reportDefinition/${reportDefinitionUuid}?v=full`; + + const { data } = useSWR<{ data: ReportDefinition }, Error>(apiUrl, openmrsFetch); + + return data?.data; +} + +export function useReportDesigns(reportDefinitionUuid: string) { + const apiUrl = `/ws/rest/v1/reportingrest/designs?reportDefinitionUuid=${reportDefinitionUuid}`; + + const { data, error, isValidating, mutate } = useSWR<{ data: { results: ReportDesign[] } }, Error>( + reportDefinitionUuid ? apiUrl : null, + openmrsFetch, + ); + + return { + reportDesigns: data?.data.results, + isError: error, + isValidating: isValidating, + mutateReportDesigns: mutate, + }; +} + +function mapDesignResults(design: any): ReportDesign { + return { + name: design.name, + uuid: design.uuid, + }; +} + +export function runReportObservable( + payload: RunReportRequest, + abortController: AbortController, +): Observable> { + return openmrsObservableFetch(`/ws/rest/v1/reportingrest/runReport`, { + signal: abortController.signal, + method: 'POST', + headers: { + 'Content-type': 'application/json', + }, + body: payload, + }); +} + +export async function cancelReportRequest(reportRequestUuid: string) { + const apiUrl = `/ws/rest/v1/reportingrest/cancelReport?reportRequestUuid=${reportRequestUuid}`; + + return openmrsFetch(apiUrl, { + method: 'DELETE', + }); +} + +export async function preserveReport(reportRequestUuid: string) { + const apiUrl = `/ws/rest/v1/reportingrest/preserveReport?reportRequestUuid=${reportRequestUuid}`; + + return openmrsFetch(apiUrl, { + method: 'POST', + }); +} + +export async function downloadReport(reportRequestUuid: string) { + const apiUrl = `/ws/rest/v1/reportingrest/downloadReport?reportRequestUuid=${reportRequestUuid}`; + + const { data } = await openmrsFetch(apiUrl); + + return data; +} + +export async function downloadMultipleReports(reportRequestUuids: string[]) { + const apiUrl = `/ws/rest/v1/reportingrest/downloadMultipleReports?reportRequestUuids=${reportRequestUuids}`; + + const { data } = await openmrsFetch(apiUrl); + + return data; +} + +function mapReportResults(data: any): ReportModel { + return { + id: data.uuid, + reportName: data.parameterizable.name, + status: data.status, + requestedBy: data.requestedBy.person.display, + requestedByUserUuid: data.requestedBy.uuid, + requestedOn: moment(data.requestDate).format('YYYY-MM-DD HH:mm'), + outputFormat: data.renderingMode.label, + parameters: convertParametersToString(data), + evaluateCompleteDatetime: data.evaluateCompleteDatetime + ? moment(data.evaluateCompleteDatetime).format('YYYY-MM-DD HH:mm') + : undefined, + schedule: data.schedule, + }; +} + +function mapScheduledReportResults(data: any): ScheduledReportModel { + return { + reportDefinitionUuid: data.reportDefinition.uuid, + reportRequestUuid: data.reportScheduleRequest?.uuid, + name: data.reportDefinition.name, + schedule: data.reportScheduleRequest?.schedule, + }; +} + +function convertParametersToString(data: any): string { + let finalString = ''; + const parameters = data.parameterizable.parameters; + if (parameters.length > 0) { + parameters.forEach((parameter) => { + let value = data.parameterMappings[parameter.name]; + if (parameter.type === 'java.util.Date') { + value = moment(value).format('YYYY-MM-DD'); + } else if (parameter.type === 'org.openmrs.Location') { + value = value?.display; + } + finalString = finalString + parameter.label + ': ' + value + ', '; + }); + + finalString = finalString.trim(); + + if (finalString.charAt(finalString.length - 1) === ',') { + finalString = finalString.slice(0, -1); + } + } + + return finalString; +} diff --git a/packages/esm-reports-app/src/components/reports.scss b/packages/esm-reports-app/src/components/reports.scss new file mode 100644 index 0000000..958856a --- /dev/null +++ b/packages/esm-reports-app/src/components/reports.scss @@ -0,0 +1,90 @@ +.statusIcon { + margin-bottom: -2px; + margin-right: 5px; +} + +.failedIcon { + color: red; +} + +.successIcon { + color: green; +} + +.runningIcon { + display: inline-block; +} + +.actionButon { + padding-left: 0px; + color: inherit; +} + +.actionButtonIcon { + margin-left: 5px; +} + +.mainActionButton { + margin-top: 25px; +} + +.mainPanelDiv { + height: 130px; + background-color: white; +} + +.emptyButton { + width: 100px; +} + +.actionButtonsWrapperDiv { + width: 110px; + display: inline-block; +} + +.mainActionButtonsDiv { + text-align: right; + height: 48px; +} + +.reportsLabelDiv { + margin-left: 20px; + padding-top: 20px; +} + +.breadcrumbsSlot { + grid-row: 1 / 2; + grid-column: 1 / 2; +} + +.downloadReportsVisible { + display: inline-block; +} + +.downloadReportsHidden { + display: none; +} + +.breadcrumb { + margin-top: -30px; +} + +.rowCellEven { + background-color: #FFFFFF !important; +} + +.rowCellOdd { + background-color: #F4F4F4 !important; +} + +.scheduledStatusText { + color: green; +} + +.scheduleCompletedStatusText { + color: red; +} + +.notScheduledStatusText { + color: red; +} diff --git a/packages/esm-reports-app/src/components/run-report/cancel-report-modal.component.tsx b/packages/esm-reports-app/src/components/run-report/cancel-report-modal.component.tsx new file mode 100644 index 0000000..f6e5554 --- /dev/null +++ b/packages/esm-reports-app/src/components/run-report/cancel-report-modal.component.tsx @@ -0,0 +1,123 @@ +import React, { useCallback, useState } from 'react'; +import { ModalBody, Button, ModalFooter, ModalHeader, InlineLoading } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { cancelReportRequest } from '../reports.resource'; +import { showToast } from '@openmrs/esm-framework'; +import { mutate } from 'swr'; + +interface CancelReportModalProps { + closeModal: () => void; + reportRequestUuid: string; + modalType: string; +} + +const CancelReportModal: React.FC = ({ closeModal, reportRequestUuid, modalType }) => { + const { t } = useTranslation(); + const [isCanceling, setIsCanceling] = useState(false); + + const handleCancel = useCallback(async () => { + setIsCanceling(true); + cancelReportRequest(reportRequestUuid) + .then(() => { + callMutates(modalType); + closeModal(); + showToast({ + critical: true, + kind: 'success', + title: getModalTitleByType(modalType), + description: getSuccessToastMessageByType(modalType), + }); + }) + .catch((error) => { + showToast({ + critical: true, + kind: 'error', + title: getModalTitleByType(modalType), + description: getFailedToastMessageByType(modalType), + }); + }); + }, [closeModal]); + + const callMutates = (modalType) => { + let baseUrl = '/ws/rest/v1/reportingrest/reportRequest?statusesGroup='; + if (modalType === 'delete') { + mutate(baseUrl + 'ran'); + } else if (modalType === 'cancel') { + mutate(baseUrl + 'ran'); + mutate(baseUrl + 'processing'); + } else if (modalType === 'schedule') { + mutate(baseUrl + 'scheduled&sortBy=name'); + } + }; + + const getModalTitleByType = (modalType) => { + if (modalType === 'delete') { + return t('deleteReport', 'Delete report'); + } else if (modalType === 'cancel') { + return t('cancelReport', 'Cancel report'); + } else if (modalType === 'schedule') { + return t('scheduleReport', 'Schedule report'); + } + }; + + const getModalBodyByType = (modalType) => { + if (modalType === 'delete') { + return t('deleteReportModalText', 'Are you sure you want to delete this report?'); + } else if (modalType === 'cancel') { + return t('cancelReportModalText', 'Are you sure you want to cancel this report?'); + } else if (modalType === 'schedule') { + return t('deleteReportScheduleModalText', 'Are you sure you want to delete this schedule?'); + } + }; + + const getSuccessToastMessageByType = (modalType) => { + if (modalType === 'delete') { + return t('reportDeletedSuccessfully', 'Report deleted successfully'); + } else if (modalType === 'cancel') { + return t('reportCancelledSuccessfully', 'Report cancelled successfully'); + } else if (modalType === 'schedule') { + return t('reportScheduleDeletedSuccessfully', 'Report schedule deleted successfully'); + } + }; + + const getFailedToastMessageByType = (modalType) => { + if (modalType === 'delete') { + return t('reportDeletingErrorMsg', 'Error during report deleting'); + } else if (modalType === 'cancel') { + return t('reportCancelingErrorMsg', 'Error during report canceling'); + } else if (modalType === 'schedule') { + return t('reportScheduleDeletingErrorMsg', 'Error during report schedule deleting'); + } + }; + + const getLoadingMessageByType = (modalType) => { + if (modalType === 'delete' || modalType === 'schedule') { + return t('deleting', 'Deleting'); + } else if (modalType === 'cancel') { + return t('canceling', 'Canceling'); + } + }; + + return ( +
+ + +

{getModalBodyByType(modalType)}

+
+ + + + +
+ ); +}; + +export default CancelReportModal; diff --git a/packages/esm-reports-app/src/components/run-report/run-report-form.component.tsx b/packages/esm-reports-app/src/components/run-report/run-report-form.component.tsx new file mode 100644 index 0000000..b16f43d --- /dev/null +++ b/packages/esm-reports-app/src/components/run-report/run-report-form.component.tsx @@ -0,0 +1,244 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './run-report-form.scss'; +import { + useLocations, + useReportDefinitions, + useReportDesigns, + runReportObservable, + RunReportRequest, +} from '../reports.resource'; +import { ReportDesign } from '../../types/report-design'; +import { closeOverlay } from '../../hooks/useOverlay'; +import { Button, ButtonSet, DatePicker, DatePickerInput, Form, Select, SelectItem, TextInput } from '@carbon/react'; +import { showToast, useLayoutType } from '@openmrs/esm-framework'; +import { first } from 'rxjs/operators'; + +interface RunReportForm { + closePanel: () => void; +} + +const RunReportForm: React.FC = ({ closePanel }) => { + const { t } = useTranslation(); + const [reportUuid, setReportUuid] = useState(''); + const [renderModeUuid, setRenderModeUuid] = useState(''); + const [currentReport, setCurrentReport] = useState(null); + const [reportParameters, setReportParameters] = useState({}); + const [isFormValid, setIsFormValid] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const isTablet = useLayoutType() === 'tablet'; + + const { reportDesigns, mutateReportDesigns } = useReportDesigns(reportUuid); + + useEffect(() => { + mutateReportDesigns(); + }, [reportUuid]); + + useEffect(() => { + const paramTypes = currentReport?.parameters.map((param) => param.type); + const isAnyNotSupportedType = !paramTypes?.every((paramType) => supportedParameterTypes.includes(paramType)); + const allParametersNotEmpty = currentReport?.parameters.every( + (parameter) => !!reportParameters[parameter.name] && reportParameters[parameter.name] !== 'Invalid Date', + ); + + if (!isAnyNotSupportedType && allParametersNotEmpty && reportUuid !== '' && renderModeUuid !== '') { + setIsFormValid(true); + } else { + setIsFormValid(false); + } + }, [reportParameters, reportUuid, renderModeUuid]); + + const supportedParameterTypes = ['java.util.Date', 'java.lang.String', 'java.lang.Integer', 'org.openmrs.Location']; + + const { reportDefinitions } = useReportDefinitions(); + const { locations } = useLocations(); + + function renderParameterElementBasedOnType(parameter: any) { + switch (parameter.type) { + case 'java.util.Date': + return ( +
+ handleOnDateChange(parameter.name, date)} + dateFormat="Y-m-d" + className={styles.datePicker} + > + + +
+ ); + case 'java.lang.String': + case 'java.lang.Integer': + return ( +
+ handleOnChange(e)} + value={reportParameters[parameter.name] ?? ''} + /> +
+ ); + case 'org.openmrs.Location': + return ( +
+ +
+ ); + default: + return ( +
+ + {`Unknown parameter type: ${parameter.type} for parameter: ${parameter.label}`} + +
+ ); + } + } + + function handleOnChange(event) { + const key = event.target.name; + let value = null; + if (event.target.type == 'checkbox') { + value = event.target.checked; + } else { + value = event.target.value; + } + + setReportParameters((state) => ({ ...state, [key]: value })); + } + + function handleOnDateChange(fieldName, dateValue) { + const date = new Date(dateValue).toLocaleDateString(); + setReportParameters((state) => ({ ...state, [fieldName]: date })); + } + + const handleSubmit = useCallback( + (event) => { + event.preventDefault(); + + setIsSubmitting(true); + + const runReportRequest: RunReportRequest = { + reportDefinitionUuid: reportUuid, + renderModeUuid: renderModeUuid, + reportParameters: reportParameters, + }; + + const abortController = new AbortController(); + runReportObservable(runReportRequest, abortController) + .pipe(first()) + .subscribe( + () => { + // delayed handling because runReport returns before new reports is accessible via GET + setTimeout(() => { + showToast({ + critical: true, + kind: 'success', + title: t('reportRunning', 'Report running'), + description: t('reportRanSuccessfullyMsg', 'Report ran successfully'), + }); + closePanel(); + setIsSubmitting(false); + }, 500); + }, + (error) => { + console.error(error); + showToast({ + critical: true, + kind: 'error', + title: t('reportRunningErrorMsg', 'Error while running the report'), + description: t('reportRunningErrorMsg', 'Error while running the report'), + }); + setIsSubmitting(false); + }, + ); + }, + [reportUuid, renderModeUuid, reportParameters], + ); + + return ( +
+
+ +
+
+ {currentReport && + currentReport.parameters?.map((parameter) =>
{renderParameterElementBasedOnType(parameter)}
)} +
+
+ +
+
+ + + + +
+
+ ); +}; + +export default RunReportForm; diff --git a/packages/esm-reports-app/src/components/run-report/run-report-form.scss b/packages/esm-reports-app/src/components/run-report/run-report-form.scss new file mode 100644 index 0000000..5f3a508 --- /dev/null +++ b/packages/esm-reports-app/src/components/run-report/run-report-form.scss @@ -0,0 +1,87 @@ +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/type'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.tablet { + padding: spacing.$spacing-06 spacing.$spacing-05; + background-color: $ui-02; +} + +.desktop { + padding: 0rem; +} + +.desktopRunReport { + background-color: $ui-background; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.runReportInnerDivElement { + display: flex; + padding: 16px; + flex-direction: column; + align-items: flex-start; + gap: 16px; +} + +.basicInputElement { + width: 300px; + height: 30px; + margin-bottom: 30px; +} + +.outputFormatDiv { + margin-bottom: 50px; + display: flex; + padding: 32px 16px 16px 16px; + flex-direction: column; + align-items: flex-start; + gap: 16px; +} + +.buttonsDiv { + margin-top: 50px; +} + +.parameterElement { + margin-bottom: 30px; + margin-top: 30px; +} + +.datePicker { + text-align: left; +} + +.cancelActionButton { + text-decoration: underline; + color: #525252; + padding-left: 0px; +} + +.unknownParameterTypeSpan { + text-align: left; + color: red; +} + +.tableIcon { + display: inline-block; + margin-left: 10px; +} + +.reportButton { + height: 4rem; + display: flex; + align-content: flex-start; + align-items: baseline; + min-width: 50%; +} + +.rowCellEven { + background-color: #FFFFFF !important; +} + +.rowCellOdd { + background-color: #F4F4F4 !important; +} diff --git a/packages/esm-reports-app/src/components/scheduled-overview-cell-content.component.tsx b/packages/esm-reports-app/src/components/scheduled-overview-cell-content.component.tsx new file mode 100644 index 0000000..7ae7b40 --- /dev/null +++ b/packages/esm-reports-app/src/components/scheduled-overview-cell-content.component.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { Edit, TrashCan } from '@carbon/react/icons'; +import { showModal, userHasAccess, useSession } from '@openmrs/esm-framework'; +import ReportScheduleDescription from './report-schedule-description.component'; +import NextReportExecution from './next-report-execution.component'; +import ReportOverviewButton from './report-overview-button.component'; +import { useTranslation } from 'react-i18next'; +import { closeOverlay, launchOverlay } from '../hooks/useOverlay'; +import styles from './reports.scss'; +import { PRIVILEGE_SYSTEM_DEVELOPER } from '../constants'; +import EditScheduledReportForm from './edit-scheduled-report/edit-scheduled-report-form.component'; +import ScheduledReportStatus from './scheduled-report-status.component'; + +interface ScheduledOverviewCellContentProps { + cell: { info: { header: string }; value: any }; + mutate: () => void; +} + +const ScheduledOverviewCellContent: React.FC = ({ cell, mutate }) => { + const { t } = useTranslation(); + const session = useSession(); + + const renderContent = () => { + switch (cell.info.header) { + case 'name': + return
{cell.value?.content ?? cell.value}
; + case 'status': + return ; + case 'schedule': + return ; + case 'nextRun': + return ; + case 'actions': + return ( +
+ } + reportRequestUuid={null} + onClick={() => { + launchOverlay( + t('editScheduledReport', 'Edit Scheduled Report'), + { + closeOverlay(); + mutate(); + }} + />, + ); + }} + /> + } + reportRequestUuid={cell.value.reportRequestUuid} + onClick={() => launchDeleteReportScheduleDialog(cell.value.reportRequestUuid)} + /> +
+ ); + default: + return {cell.value?.content ?? cell.value}; + } + }; + + const launchDeleteReportScheduleDialog = (reportRequestUuid: string) => { + const dispose = showModal('cancel-report-modal', { + closeModal: () => { + dispose(); + mutate(); + }, + reportRequestUuid, + modalType: 'schedule', + }); + }; + + return renderContent(); +}; + +export default ScheduledOverviewCellContent; diff --git a/packages/esm-reports-app/src/components/scheduled-overview.component.tsx b/packages/esm-reports-app/src/components/scheduled-overview.component.tsx new file mode 100644 index 0000000..d5868c5 --- /dev/null +++ b/packages/esm-reports-app/src/components/scheduled-overview.component.tsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; +import { ExtensionSlot, isDesktop, useLayoutType, usePagination } from '@openmrs/esm-framework'; +import styles from './reports.scss'; +import { useTranslation } from 'react-i18next'; +import { + DataTable, + Pagination, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from '@carbon/react'; +import { useScheduledReports } from './reports.resource'; +import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZES } from './pagination-constants'; +import ScheduledOverviewCellContent from './scheduled-overview-cell-content.component'; +import Overlay from './overlay.component'; + +const ScheduledOverviewComponent: React.FC = () => { + const { t } = useTranslation(); + const layout = useLayoutType(); + + const { scheduledReports, mutateScheduledReports } = useScheduledReports('name'); + const scheduledReportRows = scheduledReports + ? scheduledReports.map((report) => ({ + id: report.reportRequestUuid ? report.reportRequestUuid : report.reportDefinitionUuid, + name: report.name, + status: !!report.reportRequestUuid, + schedule: report.schedule, + nextRun: report.schedule, + actions: { + reportDefinitionUuid: report.reportDefinitionUuid, + reportRequestUuid: report.reportRequestUuid, + }, + })) + : []; + + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const { currentPage, results, goTo } = usePagination(scheduledReportRows, pageSize); + + const tableHeaders = [ + { key: 'name', header: t('reportName', 'Report Name') }, + { key: 'status', header: t('status', 'Status') }, + { key: 'schedule', header: t('schedule', 'Schedule') }, + { key: 'nextRun', header: t('nextRun', 'Next run') }, + { key: 'actions', header: t('actions', 'Actions') }, + ]; + + return ( +
+ +
+
+

{t('scheduledReports', 'Scheduld Reports')}

+
+
+ + {({ rows, headers }) => ( + + + + + {headers.map((header) => ( + {header.header?.content ?? header.header} + ))} + + + + {rows.map((row, index) => ( + + {row.cells.map((cell) => ( + + + + ))} + + ))} + +
+
+ )} +
+ { + if (newPageSize !== pageSize) { + setPageSize(newPageSize); + } + + if (newPage !== currentPage) { + goTo(newPage); + } + }} + /> +
+ +
+ ); +}; + +export default ScheduledOverviewComponent; diff --git a/packages/esm-reports-app/src/components/scheduled-report-status.component.tsx b/packages/esm-reports-app/src/components/scheduled-report-status.component.tsx new file mode 100644 index 0000000..969f4a6 --- /dev/null +++ b/packages/esm-reports-app/src/components/scheduled-report-status.component.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import styles from './reports.scss'; +import { useTranslation } from 'react-i18next'; + +interface ReportStatusProps { + hasSchedule: string; +} + +const ScheduledReportStatus: React.FC = ({ hasSchedule }) => { + const { t } = useTranslation(); + + const renderStatusLabel = () => { + if (hasSchedule) { + return {t('scheduled', 'Scheduled')}; + } else { + return {t('notScheduled', 'Not Scheduled')}; + } + }; + + return renderStatusLabel(); +}; + +export default ScheduledReportStatus; diff --git a/packages/esm-reports-app/src/components/simple-cron-editor/commons.tsx b/packages/esm-reports-app/src/components/simple-cron-editor/commons.tsx new file mode 100644 index 0000000..40ce2b9 --- /dev/null +++ b/packages/esm-reports-app/src/components/simple-cron-editor/commons.tsx @@ -0,0 +1,27 @@ +export interface CronField { + name: string; + value: number | string; +} + +export const ST_ONCE = 'once'; +export const ST_EVERY_DAY = 'everyDay'; +export const ST_EVERY_WEEK = 'everyWeek'; +export const ST_EVERY_MONTH = 'everyMonth'; +export const ST_ADVANCED = 'advanced'; +export const SCHEDULE_TYPES = [ST_ONCE, ST_EVERY_DAY, ST_EVERY_WEEK, ST_EVERY_MONTH, ST_ADVANCED]; +export const DAYS_OF_WEEK: CronField[] = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +].map((name, idx) => ({ + name, + value: idx + 1, +})); +export const DAYS_OF_MONTH: CronField[] = [ + { name: 'firstDay', value: '1' }, + { name: 'lastDay', value: 'L' }, +]; diff --git a/packages/esm-reports-app/src/components/simple-cron-editor/cron-date-picker.component.tsx b/packages/esm-reports-app/src/components/simple-cron-editor/cron-date-picker.component.tsx new file mode 100644 index 0000000..8ce3424 --- /dev/null +++ b/packages/esm-reports-app/src/components/simple-cron-editor/cron-date-picker.component.tsx @@ -0,0 +1,67 @@ +import React, { useEffect, useState } from 'react'; +import { DatePicker, DatePickerInput } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import styles from './simple-cron-editor.scss'; +import { isEqual } from 'lodash-es'; + +interface CronDatePickerProps { + value: Date; + onChange: (date: Date) => void; +} + +interface ValidationState { + invalid: boolean; + invalidText: string; +} + +const CronDatePicker: React.FC = ({ value, onChange }) => { + const { t } = useTranslation(); + const [valueInternal, setValueInternal] = useState(value); + const [validationState, setValidationState] = useState({ + invalid: false, + invalidText: null, + }); + + useEffect(() => { + if (!isEqual(value, valueInternal)) { + setValueInternal(value); + } + }, [value]); + + useEffect(() => { + validate(); + }, [valueInternal]); + + useEffect(() => { + onChange(validationState.invalid ? null : valueInternal); + }, [validationState]); + + const validate = () => { + if (!(valueInternal instanceof Date)) { + setValidationState({ invalid: true, invalidText: 'dateRequired' }); + } else { + setValidationState({ invalid: false, invalidText: null }); + } + }; + + return ( +
+ { + setValueInternal(selectedDate); + }} + > + + + {validationState.invalid && ( + {validationState.invalidText && t(validationState.invalidText)} + )} +
+ ); +}; + +export default CronDatePicker; diff --git a/packages/esm-reports-app/src/components/simple-cron-editor/cron-day-of-month-select.component.tsx b/packages/esm-reports-app/src/components/simple-cron-editor/cron-day-of-month-select.component.tsx new file mode 100644 index 0000000..cc74a7f --- /dev/null +++ b/packages/esm-reports-app/src/components/simple-cron-editor/cron-day-of-month-select.component.tsx @@ -0,0 +1,66 @@ +import React, { useEffect, useState } from 'react'; +import { Select, SelectItem } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { CronField, DAYS_OF_MONTH } from './commons'; +import { isEqual } from 'lodash-es'; + +interface CronDayOfMonthSelectProps { + value: CronField; + onChange: (selectedDayOfMonth: CronField) => void; +} + +interface ValidationState { + invalid: boolean; + invalidText: string; +} + +const CronDayOfMonthSelect: React.FC = ({ value, onChange }) => { + const { t } = useTranslation(); + + const [valueInternal, setValueInternal] = useState(value?.value); + const [validationState, setValidationState] = useState({ + invalid: false, + invalidText: null, + }); + + useEffect(() => { + if (!isEqual(value, valueInternal)) { + setValueInternal(value?.value); + } + }, [value]); + + useEffect(() => { + validate(); + }, [valueInternal]); + + useEffect(() => { + onChange(validationState.invalid ? null : DAYS_OF_MONTH.find((dayOfMonth) => dayOfMonth.value == valueInternal)); + }, [validationState]); + + const validate = () => { + if (!!valueInternal) { + setValidationState({ invalid: false, invalidText: null }); + } else { + setValidationState({ invalid: true, invalidText: 'dayOfMonthRequired' }); + } + }; + + return ( + + ); +}; + +export default CronDayOfMonthSelect; diff --git a/packages/esm-reports-app/src/components/simple-cron-editor/cron-day-of-week-select.component.tsx b/packages/esm-reports-app/src/components/simple-cron-editor/cron-day-of-week-select.component.tsx new file mode 100644 index 0000000..ae5dea5 --- /dev/null +++ b/packages/esm-reports-app/src/components/simple-cron-editor/cron-day-of-week-select.component.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useState } from 'react'; +import { FilterableMultiSelect } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { CronField, DAYS_OF_WEEK } from './commons'; +import { isEqual } from 'lodash-es'; + +interface CronDayOfWeekSelectProps { + value: CronField[]; + onChange: (selectedDaysOfWeek: CronField[]) => void; +} + +interface ValidationState { + invalid: boolean; + invalidText: string; +} + +const CronDayOfWeekSelect: React.FC = ({ value, onChange }) => { + const { t } = useTranslation(); + + const [valueInternal, setValueInternal] = useState(value); + const [validationState, setValidationState] = useState({ + invalid: false, + invalidText: null, + }); + + useEffect(() => { + if (!isEqual(value, valueInternal)) { + setValueInternal(value); + } + }, [value]); + + useEffect(() => { + validate(); + }, [valueInternal]); + + useEffect(() => { + onChange(validationState.invalid ? null : valueInternal); + }, [validationState]); + + const validate = () => { + if (!!valueInternal && valueInternal.length > 0) { + setValidationState({ invalid: false, invalidText: null }); + } else { + setValidationState({ invalid: true, invalidText: 'dayOfWeekRequired' }); + } + }; + + return ( + item1.value < item2.value} + itemToString={(item) => (item ? t('dayOfWeek_' + item.name) : '')} + selectionFeedback="fixed" + initialSelectedItems={valueInternal ? valueInternal : []} + onChange={(event) => { + setValueInternal(event.selectedItems); + }} + invalid={validationState.invalid} + invalidText={t(validationState.invalidText)} + /> + ); +}; + +export default CronDayOfWeekSelect; diff --git a/packages/esm-reports-app/src/components/simple-cron-editor/cron-time-picker.component.tsx b/packages/esm-reports-app/src/components/simple-cron-editor/cron-time-picker.component.tsx new file mode 100644 index 0000000..51c68a7 --- /dev/null +++ b/packages/esm-reports-app/src/components/simple-cron-editor/cron-time-picker.component.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState } from 'react'; +import { TimePicker } from '@carbon/react'; +import { parseTime, Time, TIME_PATTERN, to24HTime } from '../../utils/time-utils'; +import { isEqual } from 'lodash-es'; +import { useTranslation } from 'react-i18next'; + +interface CronTimePickerProps { + value: Time; + onChange: (time: Time) => void; +} + +interface ValidationState { + invalid: boolean; + invalidText: string; +} + +const CronTimePicker: React.FC = ({ value, onChange }) => { + const { t } = useTranslation(); + const timePatternExp = new RegExp(TIME_PATTERN); + const [valueInternal, setValueInternal] = useState(to24HTime(value)); + const [validationState, setValidationState] = useState({ + invalid: false, + invalidText: null, + }); + + useEffect(() => { + const newInternalValue = to24HTime(value); + if (!isEqual(value, newInternalValue)) { + setValueInternal(newInternalValue); + } + }, [value]); + + useEffect(() => { + validate(); + }, [valueInternal]); + + useEffect(() => { + onChange(validationState.invalid ? null : parseTime(valueInternal)); + }, [validationState]); + + const validate = () => { + if (timePatternExp.test(valueInternal)) { + setValidationState({ invalid: false, invalidText: null }); + } else { + setValidationState({ invalid: true, invalidText: 'notATimeText' }); + } + }; + + return ( + { + setValueInternal(event.target.value); + }} + /> + ); +}; + +export default CronTimePicker; diff --git a/packages/esm-reports-app/src/components/simple-cron-editor/simple-cron-editor.component.tsx b/packages/esm-reports-app/src/components/simple-cron-editor/simple-cron-editor.component.tsx new file mode 100644 index 0000000..d5e144e --- /dev/null +++ b/packages/esm-reports-app/src/components/simple-cron-editor/simple-cron-editor.component.tsx @@ -0,0 +1,333 @@ +import React, { useEffect, useState } from 'react'; +import moment from 'moment'; +import { Select, SelectItem, TextInput } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { Time } from '../../utils/time-utils'; +import CronDatePicker from './cron-date-picker.component'; +import CronTimePicker from './cron-time-picker.component'; +import CronDayOfWeekSelect from './cron-day-of-week-select.component'; +import { + CronField, + DAYS_OF_MONTH, + DAYS_OF_WEEK, + SCHEDULE_TYPES, + ST_ADVANCED, + ST_EVERY_DAY, + ST_EVERY_MONTH, + ST_EVERY_WEEK, + ST_ONCE, +} from './commons'; +import CronDayOfMonthSelect from './cron-day-of-month-select.component'; +import styles from './simple-cron-editor.scss'; +import { parseOpenMRSCron } from '../../utils/openmrs-cron-utiils'; + +interface SimpleCronEditorProps { + initialCron: string; + onChange: (cron: string, isValid: boolean) => void; +} + +interface EditorState { + scheduleType: string; + date: Date; + time: Time; + selectedDaysOfWeek: CronField[]; + selectedDayOfMonth: CronField; + cron: string; +} + +const EMPTY_EDITOR_STATE: EditorState = { + scheduleType: ST_ONCE, + date: null, + time: null, + selectedDaysOfWeek: [], + selectedDayOfMonth: null, + cron: null, +}; + +function getEditorState(initialCron: string): EditorState { + const scheduleType = detectSchedulingType(initialCron); + const openMRSCron = parseOpenMRSCron(initialCron); + + if (scheduleType == ST_ADVANCED) { + return { + scheduleType, + date: undefined, + selectedDayOfMonth: undefined, + selectedDaysOfWeek: [], + time: undefined, + cron: initialCron, + }; + } else if (scheduleType == ST_ONCE) { + return { + scheduleType, + date: moment() + .set('year', parseInt(openMRSCron.year)) + .set('month', parseInt(openMRSCron.month) - 1) + .set('date', parseInt(openMRSCron.day)) + .toDate(), + selectedDayOfMonth: undefined, + selectedDaysOfWeek: [], + time: { hours: parseInt(openMRSCron.hours), minutes: parseInt(openMRSCron.minutes) }, + cron: null, + }; + } else if (scheduleType == ST_EVERY_DAY) { + return { + scheduleType, + date: undefined, + selectedDayOfMonth: undefined, + selectedDaysOfWeek: [], + time: { hours: parseInt(openMRSCron.hours), minutes: parseInt(openMRSCron.minutes) }, + cron: null, + }; + } else if (scheduleType == ST_EVERY_WEEK) { + return { + scheduleType, + date: undefined, + selectedDayOfMonth: undefined, + selectedDaysOfWeek: openMRSCron.dayOfWeek + .split(',') + .map((dayId) => parseInt(dayId)) + .map((dayId) => DAYS_OF_WEEK[dayId - 1]), + time: { hours: parseInt(openMRSCron.hours), minutes: parseInt(openMRSCron.minutes) }, + cron: null, + }; + } else if (scheduleType == ST_EVERY_MONTH) { + return { + scheduleType, + date: undefined, + selectedDayOfMonth: DAYS_OF_MONTH.find((dayOfMonth) => dayOfMonth.value === openMRSCron.day), + selectedDaysOfWeek: [], + time: { hours: parseInt(openMRSCron.hours), minutes: parseInt(openMRSCron.minutes) }, + cron: null, + }; + } + + return EMPTY_EDITOR_STATE; +} + +function detectSchedulingType(expression: string) { + if (!expression) { + return null; + } + const onceRegexp = new RegExp('^0\\s\\d{1,2}\\s\\d{1,2}\\s\\d{1,2}\\s\\d{1,2}\\s[?]\\s\\d{4}'); + if (onceRegexp.test(expression)) { + return ST_ONCE; + } + const everyDayRegexp = new RegExp('^0\\s\\d{1,2}\\s\\d{1,2}\\s[*]\\s[*]\\s[?]'); + if (everyDayRegexp.test(expression)) { + return ST_EVERY_DAY; + } + const everyWeekRegexp = new RegExp('^0\\s\\d{1,2}\\s\\d{1,2}\\s[?]\\s[*]\\s[1-7*,]*'); + if (everyWeekRegexp.test(expression)) { + return ST_EVERY_WEEK; + } + const everyMonthRegexp = new RegExp('^0\\s\\d{1,2}\\s\\d{1,2}\\s[1|L]\\s[*]\\s[?]'); + if (everyMonthRegexp.test(expression)) { + return ST_EVERY_MONTH; + } + return ST_ADVANCED; +} + +const SimpleCronEditor: React.FC = ({ initialCron, onChange }) => { + const { t } = useTranslation(); + const initialScheduleType = detectSchedulingType(initialCron); + + const [editorState, setEditorState] = useState(getEditorState(initialCron)); + const [invalid, setInvalid] = useState(false); + const [cron, setCron] = useState(initialCron); + + useEffect(() => { + setEditorState(getEditorState(initialCron)); + }, [initialCron]); + + useEffect(() => { + buildCron(); + }, [editorState]); + + useEffect(() => { + onChange(cron, !invalid); + }, [cron]); + + const buildCron = () => { + if (!validateEditor()) { + setCron(null); + return; + } + + const selectedScheduleType = editorState.scheduleType; + const selectedTime = editorState.time; + + if (selectedScheduleType == ST_ADVANCED) { + setCron(editorState.cron); + } else if (selectedScheduleType == ST_ONCE) { + setCron( + `0 ${selectedTime.minutes} ${selectedTime.hours} ${editorState.date.getDate()} ${ + editorState.date.getMonth() + 1 + } ? ${editorState.date.getFullYear()}`, + ); + } else if (selectedScheduleType == ST_EVERY_DAY) { + setCron(`0 ${selectedTime.minutes} ${selectedTime.hours} * * ?`); + } else if (selectedScheduleType == ST_EVERY_WEEK) { + setCron( + `0 ${selectedTime.minutes} ${selectedTime.hours} ? * ${editorState.selectedDaysOfWeek.map( + (dayOfWeek) => dayOfWeek.value, + )}`, + ); + } else if (selectedScheduleType == ST_EVERY_MONTH) { + setCron(`0 ${selectedTime.minutes} ${selectedTime.hours} ${editorState.selectedDayOfMonth.value} * ?`); + } + }; + + const validateEditor = (): boolean => { + const selectedScheduleType = editorState.scheduleType; + const selectedTime = editorState.time; + + if (selectedScheduleType == ST_ADVANCED) { + if (!editorState.cron) { + return validationFailed(); + } + } else if (selectedScheduleType == ST_ONCE) { + if (!selectedTime || !(editorState.date instanceof Date)) { + return validationFailed(); + } + } else if (selectedScheduleType == ST_EVERY_DAY) { + if (!selectedTime) { + return validationFailed(); + } + } else if (selectedScheduleType == ST_EVERY_WEEK) { + if (!selectedTime || !editorState.selectedDaysOfWeek || editorState.selectedDaysOfWeek.length == 0) { + return validationFailed(); + } + } else if (selectedScheduleType == ST_EVERY_MONTH) { + if (!selectedTime || !editorState.selectedDayOfMonth) { + return validationFailed(); + } + } + + return validationSuccess(); + }; + + const validationFailed = (): boolean => { + setInvalid(true); + return false; + }; + + const validationSuccess = (): boolean => { + setInvalid(false); + return true; + }; + + const renderScheduleTypeSelect = () => { + return ( +
+ +
+ ); + }; + + const renderDatePicker = () => { + return ( +
+ { + setEditorState((state) => ({ ...state, date: selectedDate })); + }} + /> +
+ ); + }; + + const renderDayOfWeekSelect = () => { + return ( +
+ { + setEditorState((state) => ({ ...state, selectedDaysOfWeek })); + }} + /> +
+ ); + }; + + const renderTimePicker = () => { + return ( +
+ { + setEditorState((state) => ({ ...state, time: selectedTime })); + }} + /> +
+ ); + }; + + const renderDayOfMonthSelect = () => { + return ( +
+ { + setEditorState((state) => ({ ...state, selectedDayOfMonth })); + }} + /> +
+ ); + }; + + const renderCronInput = () => { + return ( +
+ { + setEditorState((state) => ({ ...state, cron: event.target.value })); + }} + /> +
+ ); + }; + + return ( +
+ {renderScheduleTypeSelect()} + {editorState.scheduleType != ST_ADVANCED && editorState.scheduleType != ST_EVERY_DAY && ( +
+ {t('on', 'on')} +
+ )} + {editorState.scheduleType == ST_ONCE && renderDatePicker()} + {editorState.scheduleType == ST_EVERY_WEEK && renderDayOfWeekSelect()} + {editorState.scheduleType == ST_EVERY_MONTH && renderDayOfMonthSelect()} + {editorState.scheduleType != ST_ADVANCED && ( +
+ {t('at', 'at')} +
+ )} + {editorState.scheduleType != ST_ADVANCED && renderTimePicker()} + {editorState.scheduleType == ST_ADVANCED && renderCronInput()} +
+ ); +}; + +export default SimpleCronEditor; diff --git a/packages/esm-reports-app/src/components/simple-cron-editor/simple-cron-editor.scss b/packages/esm-reports-app/src/components/simple-cron-editor/simple-cron-editor.scss new file mode 100644 index 0000000..7d23ada --- /dev/null +++ b/packages/esm-reports-app/src/components/simple-cron-editor/simple-cron-editor.scss @@ -0,0 +1,36 @@ +@use '@carbon/colors'; +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/type'; +@import '~@openmrs/esm-styleguide/src/vars'; +@import '~carbon-components/src/globals/scss/vars'; +@import '~carbon-components/src/globals/scss/mixins'; + +.dangerLabel01 { + @include type.type-style('label-01'); + color: $danger; +} + +.cronEditor { + display: inline; +} + +.cronEditorField { + float:left; +} + +.cronTimePickerField { + float:left; + width: min-content; +} + +.cronEditorFieldSeparator { + float:left; + height: 40px; + margin-top: 20px; + margin-left: 20px; + margin-right: 20px; +} + +.cronContainer { + padding: 16px; +} diff --git a/packages/esm-reports-app/src/config-schema.ts b/packages/esm-reports-app/src/config-schema.ts index c54a853..ee482da 100644 --- a/packages/esm-reports-app/src/config-schema.ts +++ b/packages/esm-reports-app/src/config-schema.ts @@ -1,3 +1,25 @@ -import { Type } from "@openmrs/esm-framework"; +import { Type, validator } from '@openmrs/esm-framework'; +/** + * This is the config schema. It expects a configuration object which + * looks like this: + * + * ```json + * { "casualGreeting": true, "whoToGreet": ["Mom"] } + * ``` + * + * In OpenMRS Microfrontends, all config parameters are optional. Thus, + * all elements must have a reasonable default. A good default is one + * that works well with the reference application. + * + * To understand the schema below, please read the configuration system + * documentation: + * https://openmrs.github.io/openmrs-esm-core/#/main/config + * Note especially the section "How do I make my module configurable?" + * https://openmrs.github.io/openmrs-esm-core/#/main/config?id=im-developing-an-esm-module-how-do-i-make-it-configurable + * and the Schema Reference + * https://openmrs.github.io/openmrs-esm-core/#/main/config?id=schema-reference + */ export const configSchema = {}; + +export type Config = {}; diff --git a/packages/esm-reports-app/src/constants.ts b/packages/esm-reports-app/src/constants.ts index c624b30..e4cf8db 100644 --- a/packages/esm-reports-app/src/constants.ts +++ b/packages/esm-reports-app/src/constants.ts @@ -1,8 +1,3 @@ -import { - omrsOfflineCachingStrategyHttpHeaderName, - type OmrsOfflineHttpHeaders, -} from "@openmrs/esm-framework"; - -export const cacheForOfflineHeaders: OmrsOfflineHttpHeaders = { - [omrsOfflineCachingStrategyHttpHeaderName]: "network-first", -}; +export const basePath = "/reports"; +export const spaBasePath = `${window.spaBase}${basePath}`; +export const PRIVILEGE_SYSTEM_DEVELOPER = "System Developer"; diff --git a/packages/esm-reports-app/src/createDashboardLink.component.tsx b/packages/esm-reports-app/src/createDashboardLink.component.tsx index c98c59f..a4b42d8 100644 --- a/packages/esm-reports-app/src/createDashboardLink.component.tsx +++ b/packages/esm-reports-app/src/createDashboardLink.component.tsx @@ -1,6 +1,4 @@ import React, { useMemo } from "react"; -import classNames from "classnames"; -import { ConfigurableLink } from "@openmrs/esm-framework"; import { BrowserRouter, useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -9,14 +7,13 @@ export interface DashboardLinkConfig { title: string; } -function DashboardExtension({ +export function DashboardExtension({ dashboardLinkConfig, }: { dashboardLinkConfig: DashboardLinkConfig; }) { - // const { t } = useTranslation(); - const { name } = dashboardLinkConfig; + const { name, title } = dashboardLinkConfig; const location = useLocation(); const spaBasePath = `${window.spaBase}/home`; @@ -26,15 +23,34 @@ function DashboardExtension({ return decodeURIComponent(lastElement); }, [location.pathname]); + const baseUrl = `${window.location.protocol}//${window.location.host}`; + const reportsUrl = `${baseUrl}/openmrs/spa/reports`; + + const handleClick = () => { + const url = name === "reports" ? reportsUrl : `${spaBasePath}/${name}`; + window.open(url, "_blank"); + }; + return ( - - {t("reports", "Reports")} - + {t(name, title)} + ); } diff --git a/packages/esm-reports-app/src/dashboard.meta.ts b/packages/esm-reports-app/src/dashboard.meta.ts index 0910d5d..084f65d 100644 --- a/packages/esm-reports-app/src/dashboard.meta.ts +++ b/packages/esm-reports-app/src/dashboard.meta.ts @@ -1,5 +1,6 @@ -export const dashboardMeta = { - name: "reports", +export const homeDashboardMeta = { slot: "reports-dashboard-slot", + path: "reports", title: "Reports", + name: "reports", }; diff --git a/packages/esm-reports-app/src/declarations.d.ts b/packages/esm-reports-app/src/declarations.d.ts index b3cf144..dda6181 100644 --- a/packages/esm-reports-app/src/declarations.d.ts +++ b/packages/esm-reports-app/src/declarations.d.ts @@ -1,5 +1,6 @@ -declare module "@carbon/react"; -declare module "*.css"; -declare module "*.scss"; -declare module "*.png"; -declare type SideNavProps = {}; +declare module '@carbon/react'; +declare module '*.css'; +declare module '*.scss'; +declare module '*.png'; + +declare type SideNavProps = object; diff --git a/packages/esm-reports-app/src/hooks/useOverlay.tsx b/packages/esm-reports-app/src/hooks/useOverlay.tsx new file mode 100644 index 0000000..3716d29 --- /dev/null +++ b/packages/esm-reports-app/src/hooks/useOverlay.tsx @@ -0,0 +1,45 @@ +import { getGlobalStore } from '@openmrs/esm-framework'; +import { useCallback, useEffect, useState } from 'react'; + +export interface OverlayStore { + isOverlayOpen: boolean; + component?: any; + header: string; +} + +const initialState: OverlayStore = { isOverlayOpen: false, header: '' }; + +const getOverlayStore = () => { + return getGlobalStore('reports-store', initialState); +}; + +export const launchOverlay = (headerTitle: string, componentToRender: any) => { + const store = getOverlayStore(); + store.setState({ isOverlayOpen: true, component: componentToRender, header: headerTitle }); +}; + +export const closeOverlay = (): void => { + const store = getOverlayStore(); + store.setState({ component: null, isOverlayOpen: false }); +}; + +export const useOverlay = () => { + const [overlay, setOverlay] = useState(); + + const update = useCallback((state: OverlayStore) => { + setOverlay(state); + }, []); + + useEffect(() => { + update(getOverlayStore().getState()); + getOverlayStore().subscribe(update); + }, [update]); + + const { isOverlayOpen, component, header } = overlay ?? {}; + + return { + isOverlayOpen, + component, + header, + }; +}; diff --git a/packages/esm-reports-app/src/index.ts b/packages/esm-reports-app/src/index.ts index 6c90206..026a738 100644 --- a/packages/esm-reports-app/src/index.ts +++ b/packages/esm-reports-app/src/index.ts @@ -1,13 +1,19 @@ +/** + * This is the entrypoint file of the application. It communicates the + * important features of this microfrontend to the app shell. It + * connects the app shell to the React application(s) that make up this + * microfrontend. + */ import { - defineConfigSchema, getAsyncLifecycle, + defineConfigSchema, + registerBreadcrumbs, getSyncLifecycle, } from "@openmrs/esm-framework"; import { configSchema } from "./config-schema"; -import { createDashboardLink } from "./createDashboardLink.component"; -import { dashboardMeta } from "./dashboard.meta"; -import { setupOffline } from "./offline"; -import rootComponent from "./root.component"; +import { homeDashboardMeta } from "./dashboard.meta"; +import { createDashboardLink as createHomeDashboardLink } from "./createDashboardLink.component"; + const moduleName = "@sjthc/esm-reports-app"; @@ -16,6 +22,11 @@ const options = { moduleName, }; +/** + * This tells the app shell how to obtain translation files: that they + * are JSON files in the directory `../translations` (which you should + * see in the directory structure). + */ export const importTranslation = require.context( "../translations", false, @@ -23,14 +34,82 @@ export const importTranslation = require.context( "lazy" ); +/** + * This function performs any setup that should happen at microfrontend + * load-time (such as defining the config schema) and then returns an + * object which describes how the React application(s) should be + * rendered. + */ export function startupApp() { - setupOffline(); defineConfigSchema(moduleName, configSchema); + + registerBreadcrumbs([ + { + title: "Home", + path: `${window.openmrsBase}`, + }, + { + path: `${window.spaBase}/system-administration`, + title: () => + Promise.resolve( + window.i18next.t("systemAdmin", "System Administration") + ), + parent: `${window.spaBase}/home`, + }, + { + title: () => Promise.resolve(window.i18next.t("reports", "Reports")), + path: `${window.spaBase}/reports`, + parent: `${window.spaBase}/system-administration`, + }, + { + title: () => + Promise.resolve( + window.i18next.t("scheduledReports", "Scheduled Reports") + ), + path: `${window.spaBase}/reports/scheduled-overview`, + parent: `${window.spaBase}/reports`, + }, + ]); } -export const root = getSyncLifecycle(rootComponent, options); +/** + * This named export tells the app shell that the default export of `root.component.tsx` + * should be rendered when the route matches `root`. The full route + * will be `openmrsSpaBase() + 'root'`, which is usually + * `/openmrs/spa/root`. + */ + +export const root = getAsyncLifecycle( + () => import("./reports.component"), + options +); + +export const reportsLink = getAsyncLifecycle( + () => import("./reports-link"), + options +); + +export const overview = getAsyncLifecycle( + () => import("./components/overview.component"), + options +); + +export const scheduledOverview = getAsyncLifecycle( + () => import("./components/scheduled-overview.component"), + options +); + +export const runReport = getAsyncLifecycle( + () => import("./components/run-report/run-report-form.component"), + options +); + +export const cancelReportModal = getAsyncLifecycle( + () => import("./components/run-report/cancel-report-modal.component"), + options +); -export const reportsDashboardLink = getSyncLifecycle( - createDashboardLink(dashboardMeta), +export const homeReportsLink = getSyncLifecycle( + createHomeDashboardLink(homeDashboardMeta), options ); diff --git a/packages/esm-reports-app/src/offline.ts b/packages/esm-reports-app/src/offline.ts index dfb3c6e..db8ce36 100644 --- a/packages/esm-reports-app/src/offline.ts +++ b/packages/esm-reports-app/src/offline.ts @@ -1,5 +1,4 @@ import { setupDynamicOfflineDataHandler } from "@openmrs/esm-framework"; -import { cacheForOfflineHeaders } from "./constants"; export function setupOffline() { setupDynamicOfflineDataHandler({ diff --git a/packages/esm-reports-app/src/reports-link.tsx b/packages/esm-reports-app/src/reports-link.tsx new file mode 100644 index 0000000..ebc2a27 --- /dev/null +++ b/packages/esm-reports-app/src/reports-link.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { spaBasePath } from "./constants"; +import { ClickableTile, Layer } from "@carbon/react"; +import { ArrowRight } from "@carbon/react/icons"; + +export default function ReportsLink() { + const { t } = useTranslation(); + return ( + + +
+
{t("manageReports", "Manage Reports")}
+
{t("reports", "Reports")}
+
+
+ +
+
+
+ ); +} diff --git a/packages/esm-reports-app/src/reports.component.tsx b/packages/esm-reports-app/src/reports.component.tsx new file mode 100644 index 0000000..b7a67ec --- /dev/null +++ b/packages/esm-reports-app/src/reports.component.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import styles from "./root.scss"; +import OverviewComponent from "./components/overview.component"; +import ScheduledOverviewComponent from "./components/scheduled-overview.component"; +import { spaBasePath } from "./constants"; + +const RootComponent: React.FC = () => { + return ( +
+ + + } /> + } + /> + + +
+ ); +}; + +export default RootComponent; diff --git a/packages/esm-reports-app/src/resources/resources.component.tsx b/packages/esm-reports-app/src/resources/resources.component.tsx new file mode 100644 index 0000000..11913ba --- /dev/null +++ b/packages/esm-reports-app/src/resources/resources.component.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { ClickableTile } from "@carbon/react"; +import { ChevronRight } from "@carbon/react/icons"; +import { useTranslation } from "react-i18next"; +import styles from "./resources.scss"; + +function Resources() { + const { t } = useTranslation(); + + return ( +
+

{t("resources", "Resources")}

+ + {t("usefulLinks", "Below are some links to useful resources")}: + +
+ + + + +
+
+ ); +} + +function Card({ + title, + subtitle, + link, +}: { + title: string; + subtitle: string; + link: string; +}) { + return ( + +
+
+

{title}

+ +
+ {subtitle} +
+
+ ); +} + +export default Resources; diff --git a/packages/esm-reports-app/src/resources/resources.scss b/packages/esm-reports-app/src/resources/resources.scss new file mode 100644 index 0000000..8671124 --- /dev/null +++ b/packages/esm-reports-app/src/resources/resources.scss @@ -0,0 +1,62 @@ +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/type'; + +.container { + padding: spacing.$spacing-07; +} + +.heading { + @include type.type-style('heading-04'); + margin: spacing.$spacing-05 0; +} + +.explainer { + display: block; +} + +.resources { + margin-top: spacing.$spacing-10; + margin-bottom: spacing.$spacing-09; + + > * + * { + margin-right: spacing.$spacing-05; + } +} + +.cardsContainer { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + column-gap: spacing.$spacing-06; + margin-top: spacing.$spacing-07; + max-width: 75%; +} + +.card { + margin: spacing.$spacing-03 0; + display: flex; + align-items: center; + + &:hover { + border: 1px solid lightgray; + } + + svg { + margin-left: spacing.$spacing-02; + } +} + +.cardContent { + display: flex; + flex-direction: column; +} + +.title { + display: flex; + align-items: center; + margin-bottom: spacing.$spacing-05; + + h4 { + @include type.type-style('heading-02'); + } +} + diff --git a/packages/esm-reports-app/src/root.component.tsx b/packages/esm-reports-app/src/root.component.tsx index a768318..4eeb2af 100644 --- a/packages/esm-reports-app/src/root.component.tsx +++ b/packages/esm-reports-app/src/root.component.tsx @@ -1,10 +1,10 @@ import React from "react"; +import { SWRConfig } from "swr"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; + const swrConfiguration = { errorRetryCount: 3, }; -import { SWRConfig } from "swr"; -import { BrowserRouter, Route, Routes } from "react-router-dom"; -import HomeDashboard from "./reports-dashboard/home-dashboard.component"; const RootComponent: React.FC = () => { const baseName = window.getOpenmrsSpaBase() + "home/reports"; @@ -14,7 +14,7 @@ const RootComponent: React.FC = () => { - } /> + diff --git a/packages/esm-reports-app/src/root.scss b/packages/esm-reports-app/src/root.scss new file mode 100644 index 0000000..49d113a --- /dev/null +++ b/packages/esm-reports-app/src/root.scss @@ -0,0 +1,15 @@ +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/type'; + +.container { + padding: spacing.$spacing-07; +} + +.welcome { + @include type.type-style('heading-04'); + margin: spacing.$spacing-05 0; +} + +.explainer { + margin-bottom: 2rem; +} diff --git a/packages/esm-reports-app/src/routes.json b/packages/esm-reports-app/src/routes.json index b109d10..c7ea53f 100644 --- a/packages/esm-reports-app/src/routes.json +++ b/packages/esm-reports-app/src/routes.json @@ -1,23 +1,48 @@ { "$schema": "https://json.openmrs.org/routes.schema.json", "backendDependencies": { - "webservices.rest": "^2.2.0" + "fhir2": "^1.2.0", + "webservices.rest": "^2.24.0" }, "extensions": [ { - "name": "reports-dashboard-link", - "component": "reportsDashboardLink", + "name": "admin-report-link", + "slot": "system-admin-page-card-link-slot", + "component": "reportsLink" + }, + { + "name": "run-report", + "component": "runReport" + }, + { + "name": "cancel-report-modal", + "component": "cancelReportModal" + }, + { + "name": "home-reports-dashboard-link", + "component": "homeReportsLink", "slot": "homepage-dashboard-slot", "meta": { "name": "reports", "slot": "reports-dashboard-slot", "title": "Reports" - } - }, + }, + "online": true, + "offline": true + } + ], + "pages": [ { "component": "root", - "name": "reports-dashboard", - "slot": "reports-dashboard-slot" + "route": "reports" + }, + { + "component": "overview", + "route": "overview" + }, + { + "component": "scheduled-overview", + "route": "scheduled-overview" } ] } diff --git a/packages/esm-reports-app/src/setup-tests.ts b/packages/esm-reports-app/src/setup-tests.ts new file mode 100644 index 0000000..666127a --- /dev/null +++ b/packages/esm-reports-app/src/setup-tests.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/extend-expect'; diff --git a/packages/esm-reports-app/src/types/report-definition.ts b/packages/esm-reports-app/src/types/report-definition.ts new file mode 100644 index 0000000..c4c4d85 --- /dev/null +++ b/packages/esm-reports-app/src/types/report-definition.ts @@ -0,0 +1,12 @@ +export interface ReportDefinition { + uuid: string; + name: string; + description: string; + parameters: ReportParameter[]; +} + +interface ReportParameter { + name: string; + label: string; + type: string; +} diff --git a/packages/esm-reports-app/src/types/report-design.ts b/packages/esm-reports-app/src/types/report-design.ts new file mode 100644 index 0000000..78da251 --- /dev/null +++ b/packages/esm-reports-app/src/types/report-design.ts @@ -0,0 +1,4 @@ +export interface ReportDesign { + name: string; + uuid: string; +} diff --git a/packages/esm-reports-app/src/types/report-request.ts b/packages/esm-reports-app/src/types/report-request.ts new file mode 100644 index 0000000..2a68e04 --- /dev/null +++ b/packages/esm-reports-app/src/types/report-request.ts @@ -0,0 +1,8 @@ +export interface ReportRequest { + uuid: string; + schedule: string; + renderingMode: { + argument: string; + }; + parameterMappings: any; +} diff --git a/packages/esm-reports-app/src/utils/date-utils.tsx b/packages/esm-reports-app/src/utils/date-utils.tsx new file mode 100644 index 0000000..dfe01c5 --- /dev/null +++ b/packages/esm-reports-app/src/utils/date-utils.tsx @@ -0,0 +1,5 @@ +export function getStartOfToday(now?: Date) { + let today = now ? now : new Date(); + today.setUTCHours(0, 0, 0, 0); + return today; +} diff --git a/packages/esm-reports-app/src/utils/openmrs-cron-utiils.tsx b/packages/esm-reports-app/src/utils/openmrs-cron-utiils.tsx new file mode 100644 index 0000000..361e559 --- /dev/null +++ b/packages/esm-reports-app/src/utils/openmrs-cron-utiils.tsx @@ -0,0 +1,26 @@ +export interface OpenMRSCron { + seconds: string; + minutes: string; + hours: string; + day: string; + month: string; + dayOfWeek: string; + year: string; +} + +export function parseOpenMRSCron(expression: string): OpenMRSCron { + if (!expression) { + return null; + } + + const tokens = expression.split(' '); + return { + seconds: tokens[0], + minutes: tokens[1], + hours: tokens[2], + day: tokens[3], + month: tokens[4], + dayOfWeek: tokens[5], + year: tokens[6], + }; +} diff --git a/packages/esm-reports-app/src/utils/time-utils.tsx b/packages/esm-reports-app/src/utils/time-utils.tsx new file mode 100644 index 0000000..6496e26 --- /dev/null +++ b/packages/esm-reports-app/src/utils/time-utils.tsx @@ -0,0 +1,26 @@ +export const TIME_PATTERN = '^(2[0-3]|[0-1]?[0-9]):([0-5][0-9])$'; + +export interface Time { + hours: number; + minutes: number; +} + +export function parseTime(text: string): Time | null { + if (!text || text.indexOf(':') < 0) { + return null; + } + + const parts = text.split(':'); + + return { hours: parseInt(parts[0]), minutes: parseInt(parts[1]) }; +} + +export function to24HTime(date: Date | Time): string { + if (!date) { + return null; + } else if (date instanceof Date) { + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; + } else { + return `${String(date.hours).padStart(2, '0')}:${String(date.minutes).padStart(2, '0')}`; + } +} diff --git a/packages/esm-reports-app/translations/ar.json b/packages/esm-reports-app/translations/ar.json index ecca538..2920e03 100644 --- a/packages/esm-reports-app/translations/ar.json +++ b/packages/esm-reports-app/translations/ar.json @@ -1,3 +1,77 @@ { - "actions": "الإجراءات" + "actions": "Actions", + "at": "at", + "cancel": "Cancel", + "canceling": "Canceling", + "cancelReport": "Cancel report", + "cancelReportModalText": "Are you sure you want to cancel this report?", + "completed": "Completed", + "completedAndPreserved": "Completed and Preserved", + "connect": "Connect", + "connectExplainer": "Get in touch with the community", + "delete": "Delete", + "deleteReport": "Delete report", + "deleteReportModalText": "Are you sure you want to delete this report?", + "deleteReportScheduleModalText": "Are you sure you want to delete this schedule?", + "deleteSchedule": "Delete Schedule", + "deleting": "Deleting", + "designDocs": "Design docs", + "designDocsExplainer": "Read the O3 design documentation", + "download": "Download", + "downloadReport": "Download report(s)s", + "downloadReports": "Download reports", + "edit": "Edit", + "editScheduledReport": "Edit Scheduled Report", + "failed": "Failed", + "frontendDocs": "Frontend docs", + "getStarted": "Get started", + "getStartedExplainer": "Create a frontend module from this template", + "learnExplainer": "Learn how to use the O3 framework", + "nextPage": "Next page", + "nextRun": "Next run", + "no": "No", + "notScheduled": "Not Scheduled", + "on": "on", + "outputFormat": "Output format", + "parameters": "Parameters", + "preserve": "Preserve", + "preserveReport": "Preserve report", + "previousPage": "Previous page", + "queued": "Queued", + "reportCancelingErrorMsg": "Error during report canceling", + "reportCancelledSuccessfully": "Report cancelled successfully", + "reportDeletedSuccessfully": "Report deleted successfully", + "reportDeletingErrorMsg": "Error during report deleting", + "reportDownloadedSuccessfully": "Report(s) downloaded successfully", + "reportDownloadingErrorMsg": "Error during report(s) downloading", + "reportName": "Report Name", + "reportPreservedSuccessfully": "Report preserved successfully", + "reportPreservingErrorMsg": "Error during report preserving", + "reportRanSuccessfullyMsg": "Report ran successfully", + "reportRunning": "Report running", + "reportRunningErrorMsg": "Error while running the report", + "reports": "Reports", + "reportSchedule": "Report schedule", + "reportScheduled": "Report scheduled", + "reportScheduleDeletedSuccessfully": "Report schedule deleted successfully", + "reportScheduleDeletingErrorMsg": "Error during report schedule deleting", + "reportScheduledErrorMsg": "Failed to schedule a report", + "reportScheduledSuccessfullyMsg": "Report scheduled successfully", + "requestedBy": "Requested by", + "requestedOn": "Requested on", + "resources": "Resources", + "run": "Run", + "running": "Running", + "runReport": "Run Report", + "runReports": "Run reports", + "save": "Save", + "schedule": "Schedule", + "scheduleCompleted": "Schedule completed", + "scheduled": "Scheduled", + "scheduledReports": "Scheduld Reports", + "scheduleReport": "Schedule report", + "selectReportLabel": "Report", + "status": "Status", + "usefulLinks": "Below are some links to useful resources", + "yes": "Yes" } diff --git a/packages/esm-reports-app/translations/en.json b/packages/esm-reports-app/translations/en.json index 7eec8dd..4f2fc2f 100644 --- a/packages/esm-reports-app/translations/en.json +++ b/packages/esm-reports-app/translations/en.json @@ -1,3 +1,77 @@ { - "reports": "Reports" + "actions": "Actions", + "at": "at", + "cancel": "Cancel", + "canceling": "Canceling", + "cancelReport": "Cancel report", + "cancelReportModalText": "Are you sure you want to cancel this report?", + "completed": "Completed", + "completedAndPreserved": "Completed and Preserved", + "connect": "Connect", + "connectExplainer": "Get in touch with the community", + "delete": "Delete", + "deleteReport": "Delete report", + "deleteReportModalText": "Are you sure you want to delete this report?", + "deleteReportScheduleModalText": "Are you sure you want to delete this schedule?", + "deleteSchedule": "Delete Schedule", + "deleting": "Deleting", + "designDocs": "Design docs", + "designDocsExplainer": "Read the O3 design documentation", + "download": "Download", + "downloadReport": "Download report(s)", + "downloadReports": "Download reports", + "edit": "Edit", + "editScheduledReport": "Edit Scheduled Report", + "failed": "Failed", + "frontendDocs": "Frontend docs", + "getStarted": "Get started", + "getStartedExplainer": "Create a frontend module from this template", + "learnExplainer": "Learn how to use the O3 framework", + "nextPage": "Next page", + "nextRun": "Next run", + "no": "No", + "notScheduled": "Not Scheduled", + "on": "on", + "outputFormat": "Output format", + "parameters": "Parameters", + "preserve": "Preserve", + "preserveReport": "Preserve report", + "previousPage": "Previous page", + "queued": "Queued", + "reportCancelingErrorMsg": "Error during report canceling", + "reportCancelledSuccessfully": "Report cancelled successfully", + "reportDeletedSuccessfully": "Report deleted successfully", + "reportDeletingErrorMsg": "Error during report deleting", + "reportDownloadedSuccessfully": "Report(s) downloaded successfully", + "reportDownloadingErrorMsg": "Error during report(s) downloading", + "reportName": "Report Name", + "reportPreservedSuccessfully": "Report preserved successfully", + "reportPreservingErrorMsg": "Error during report preserving", + "reportRanSuccessfullyMsg": "Report ran successfully", + "reportRunning": "Report running", + "reportRunningErrorMsg": "Error while running the report", + "reports": "Reports", + "reportSchedule": "Report schedule", + "reportScheduled": "Report scheduled", + "reportScheduleDeletedSuccessfully": "Report schedule deleted successfully", + "reportScheduleDeletingErrorMsg": "Error during report schedule deleting", + "reportScheduledErrorMsg": "Failed to schedule a report", + "reportScheduledSuccessfullyMsg": "Report scheduled successfully", + "requestedBy": "Requested by", + "requestedOn": "Requested on", + "resources": "Resources", + "run": "Run", + "running": "Running", + "runReport": "Run Report", + "runReports": "Run reports", + "save": "Save", + "schedule": "Schedule", + "scheduleCompleted": "Schedule Completed", + "scheduled": "Scheduled", + "scheduledReports": "Scheduled Reports", + "scheduleReport": "Schedule report", + "selectReportLabel": "Report", + "status": "Status", + "usefulLinks": "Below are some links to useful resources", + "yes": "Yes" } diff --git a/packages/esm-reports-app/tsconfig.json b/packages/esm-reports-app/tsconfig.json index 54ce28c..72a7d91 100644 --- a/packages/esm-reports-app/tsconfig.json +++ b/packages/esm-reports-app/tsconfig.json @@ -1,5 +1,5 @@ { "extends": "../../tsconfig.json", "include": ["src/**/*"], - "exclude": ["src/**/*.test.tsx"] + "exclude": ["src/**/*.test.tsx", "src/**/*.outdated.tsx"] } diff --git a/yarn.lock b/yarn.lock index 031e95d..1e53a38 100755 --- a/yarn.lock +++ b/yarn.lock @@ -1846,6 +1846,23 @@ __metadata: languageName: node linkType: hard +"@datasert/cronjs-matcher@npm:^1.2.0": + version: 1.4.0 + resolution: "@datasert/cronjs-matcher@npm:1.4.0" + dependencies: + "@datasert/cronjs-parser": ^1.4.0 + luxon: ^3.0.4 + checksum: b1b55041fc74de3dc35c59fbde27c2b7ffe1d24dbdeaa272d9d5746c7781b4158644e01191ba6737cca793c386e7321f5962b2bfe7d0ebd13a8656243037fc5e + languageName: node + linkType: hard + +"@datasert/cronjs-parser@npm:^1.2.0, @datasert/cronjs-parser@npm:^1.4.0": + version: 1.4.0 + resolution: "@datasert/cronjs-parser@npm:1.4.0" + checksum: 0749279273efa4cfe4227d0fc8102a9185d6c2fc06fcae7394190835f9ef58a2191e614432a7b868a14a230ebd6c80020d5e78f05ed3674d6293d1b8b8ba0cad + languageName: node + linkType: hard + "@discoveryjs/json-ext@npm:0.5.7, @discoveryjs/json-ext@npm:^0.5.0": version: 0.5.7 resolution: "@discoveryjs/json-ext@npm:0.5.7" @@ -5648,21 +5665,24 @@ __metadata: languageName: unknown linkType: soft -"@sjthc/esm-reports-app@workspace:packages/esm-patient-list-management-app": +"@sjthc/esm-reports-app@workspace:packages/esm-reports-app": version: 0.0.0-use.local - resolution: "@sjthc/esm-reports-app@workspace:packages/esm-patient-list-management-app" + resolution: "@sjthc/esm-reports-app@workspace:packages/esm-reports-app" dependencies: - "@carbon/react": ~1.37.0 - dexie: ^3.0.3 - fuzzy: ^0.1.3 - lodash-es: ^4.17.15 - webpack: ^5.74.0 + "@carbon/react": ^1.33.1 + "@datasert/cronjs-matcher": ^1.2.0 + "@datasert/cronjs-parser": ^1.2.0 + cronstrue: ^2.41.0 + dayjs: ^1.8.36 + lodash-es: ^4.17.21 + react-image-annotate: ^1.8.0 peerDependencies: - "@openmrs/esm-framework": 5.x + "@openmrs/esm-framework": "*" + dayjs: 1.x react: 18.x react-i18next: 11.x react-router-dom: 6.x - swr: 2.x + rxjs: 6.x languageName: unknown linkType: soft @@ -9555,6 +9575,15 @@ __metadata: languageName: node linkType: hard +"cronstrue@npm:^2.41.0": + version: 2.50.0 + resolution: "cronstrue@npm:2.50.0" + bin: + cronstrue: bin/cli.js + checksum: bf6e51c4b9ab28d7ba928a392a76b7d97bd3c3dc8da5618db8424480dc6213cafed658ea835925675767fe5497931d1325e51634eeb8e2556f0630a62eb29cc3 + languageName: node + linkType: hard + "cross-env@npm:^7.0.3": version: 7.0.3 resolution: "cross-env@npm:7.0.3" @@ -10332,6 +10361,13 @@ __metadata: languageName: node linkType: hard +"dayjs@npm:^1.8.36": + version: 1.11.11 + resolution: "dayjs@npm:1.11.11" + checksum: 84788275aad8a87fee4f1ce4be08861df29687aae6b7b43dd65350118a37dda56772a3902f802cb2dc651dfed447a5a8df62d88f0fb900dba8333e411190a5d5 + languageName: node + linkType: hard + "de-indent@npm:^1.0.2": version: 1.0.2 resolution: "de-indent@npm:1.0.2" @@ -12729,13 +12765,6 @@ __metadata: languageName: node linkType: hard -"fuzzy@npm:^0.1.3": - version: 0.1.3 - resolution: "fuzzy@npm:0.1.3" - checksum: acc09c6173e12d5dc8ae51857551ddbe834befa9ebc6be6d5581d09117265d704809d80407d220fd0652f347a9975a4d106854cacc8bd031487a0ede86982f84 - languageName: node - linkType: hard - "gauge@npm:^4.0.3": version: 4.0.4 resolution: "gauge@npm:4.0.4" @@ -16605,6 +16634,13 @@ __metadata: languageName: node linkType: hard +"luxon@npm:^3.0.4": + version: 3.4.4 + resolution: "luxon@npm:3.4.4" + checksum: 36c1f99c4796ee4bfddf7dc94fa87815add43ebc44c8934c924946260a58512f0fd2743a629302885df7f35ccbd2d13f178c15df046d0e3b6eb71db178f1c60c + languageName: node + linkType: hard + "lz-string@npm:^1.5.0": version: 1.5.0 resolution: "lz-string@npm:1.5.0"