diff --git a/app/public/locales/de/k3k.ftl b/app/public/locales/de/k3k.ftl index d7208bf7ddecfa64561b7ec7c7c4ab02025f78ad..60142579f7b09838f3719d419c52bff9f18b4365 100644 --- a/app/public/locales/de/k3k.ftl +++ b/app/public/locales/de/k3k.ftl @@ -949,7 +949,7 @@ dashboard-meeting-recurrence-daily = Täglich dashboard-meeting-recurrence-weekly = Wöchentlich dashboard-meeting-recurrence-bi-weekly = 14-Tägig dashboard-meeting-recurrence-monthly = Monatlich -dashboard-meeting-recurrence-custom = Benutzerdefiniert ... +dashboard-meeting-recurrence-custom = Benutzerdefiniert dashboard-recurrence-dialog-title = Benutzerdefinierte Meeting-Wiederholung dashboard-recurrence-dialog-frequency-label = Wiederholen alle @@ -978,6 +978,20 @@ dashboard-recurrence-dialog-end-option-on = Am dashboard-recurrence-dialog-save-button = Speichern dashboard-recurrence-dialog-close-button = Abbrechen +dashboard-meeting-training-participation-report-switch = Teilnahmebericht für Schulungen +dashboard-meeting-training-participation-report-option-every-thirty-min = Alle 30 Min. nachfragen +dashboard-meeting-training-participation-report-option-every-sixty-min = Alle 60 Min. nachfragen +dashboard-meeting-training-participation-report-option-thirty-to-sixty-min = Variabel alle 30 - 60 Min. nachfragen +dashboard-meeting-training-participation-report-option-ninety-to-hundred-twenty-min = Variabel alle 90 - 120 Min. nachfragen +dashboard-meeting-training-participation-report-option-custom = Benutzerdefiniert + +dashboard-custom-training-participation-report-dialog-title = Benutzerdefinierte Abfragezeiten +dashboard-custom-training-participation-report-dialog-initial-timeout = Erste Bestätigungsabfrage variabel nach +dashboard-custom-training-participation-report-dialog-interval-duration = Nachfolgende Abfragen variabel nach +dashboard-custom-training-participation-report-dialog-to = bis +dashboard-custom-training-participation-report-dialog-minutes = Minuten +dashboard-custom-training-participation-report-dialog-error = Der Wert muss größer als {$min} sein + dashboard-payment-status-downgraded = Achtung: Für Ihren Account ist derzeit keine gültige Zahlmethode hinterlegt.<br /> Die Nutzung ist derzeit auf den {$tariffName}-Tarif eingeschränkt. dashboard-add-payment-button = Jetzt auswählen @@ -1216,8 +1230,8 @@ timer-not-done-icon-title = Nicht fertig participation-confirmation-dialog-title = Bestätigung der Schulungs-Teilnahme participation-confirmation-dialog-description = Sie nehmen an einer Schulung oder einem Kurs teil. Für den Teilnahmebericht wird die Anwesenheit wiederholt abgefragt. participation-confirmation-dialog-confirm-button = Anwesenheit bestätigen -training-participation-logging-disable-button = Schulungs-Teilnahmeprotokollierung aktivieren -training-participation-logging-enable-button = Schulungs-Teilnahmeprotokollierung deaktivieren +training-participation-logging-disable-button = Schulungs-Teilnahmeprotokollierung deaktivieren +training-participation-logging-enable-button = Schulungs-Teilnahmeprotokollierung aktivieren training-participation-report-pdf-asset-notification = <messageContainer>Der Teilnahmebericht wurde exportiert. Die entsprechende Datei befindet sich im Dashboard unter den <messageLink>Meeting Details</messageLink>.</messageContainer> presence-logging-enabled-notification = Schulungs-Teilnahmeprotokollierung aktiviert presence-logging-disabled-notification = Schulungs-Teilnahmeprotokollierung deaktiviert diff --git a/app/public/locales/en/k3k.ftl b/app/public/locales/en/k3k.ftl index 3c032e6079cc6f80911bdd67a853f0c93ef47ca9..70e0164d47d8e61265a6410cf300f8ac0b363b36 100644 --- a/app/public/locales/en/k3k.ftl +++ b/app/public/locales/en/k3k.ftl @@ -949,7 +949,7 @@ dashboard-meeting-recurrence-daily = Daily dashboard-meeting-recurrence-weekly = Weekly dashboard-meeting-recurrence-bi-weekly = Bi-Weekly dashboard-meeting-recurrence-monthly = Monthly -dashboard-meeting-recurrence-custom = Custom ... +dashboard-meeting-recurrence-custom = Custom dashboard-recurrence-dialog-title = Custom meeting repetition dashboard-recurrence-dialog-frequency-label = Repeat every @@ -978,6 +978,20 @@ dashboard-recurrence-dialog-end-option-on = On dashboard-recurrence-dialog-save-button = Save dashboard-recurrence-dialog-close-button = Cancel +dashboard-meeting-training-participation-report-switch = Training participation report +dashboard-meeting-training-participation-report-option-every-thirty-min = Ask every 30 min +dashboard-meeting-training-participation-report-option-every-sixty-min = Ask every 60 min +dashboard-meeting-training-participation-report-option-thirty-to-sixty-min = Variable ask every 30 - 60 min +dashboard-meeting-training-participation-report-option-ninety-to-hundred-twenty-min = Variable ask every 90 - 120 min +dashboard-meeting-training-participation-report-option-custom = Custom + +dashboard-custom-training-participation-report-dialog-title = Custom query times +dashboard-custom-training-participation-report-dialog-initial-timeout = First confirmation query variable after +dashboard-custom-training-participation-report-dialog-interval-duration = Subsequent queries variable after +dashboard-custom-training-participation-report-dialog-to = to +dashboard-custom-training-participation-report-dialog-minutes = minutes +dashboard-custom-training-participation-report-dialog-error = The value must be greater than {$min} + dashboard-payment-status-downgraded = Attention: There is currently no valid payment method stored for your account.<br /> Currently you are restricted to the {$tariffName} plan. dashboard-add-payment-button = Add payment diff --git a/app/src/api/types/outgoing/trainingParticipationReport.ts b/app/src/api/types/outgoing/trainingParticipationReport.ts index 11486f30a46a063f8fc80f5acc9aeb782d9a5fe5..f2ecc586be7b361b9909d76d8cfb1d60f3e90d4b 100644 --- a/app/src/api/types/outgoing/trainingParticipationReport.ts +++ b/app/src/api/types/outgoing/trainingParticipationReport.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: OpenTalk GmbH <mail@opentalk.eu> // // SPDX-License-Identifier: EUPL-1.2 +import { TrainingParticipationReportParameterSet } from '@opentalk/rest-api-rtk-query/src/types/event'; + import type { RootState } from '../../../store'; import { createModule, Namespaced } from '../../../types'; import { createSignalingApiCall } from '../../createSignalingApiCall'; @@ -20,32 +22,12 @@ export enum ParticipationLoggingState { */ WaitingForConfirmation = 'waiting_for_confirmation', } - export type ParticipationLogging = { state: ParticipationLoggingState; }; -export interface TimeRange { - /** - * The earliest number of seconds after which the checkpoint can be created. Must be a strictly positive number. - */ - after: number; - /** - * The number of seconds within which the checkpoint can be created after the after value. Must be 0 or greater. - */ - within: number; -} - -export interface EnablePresenceLogging { +export interface EnablePresenceLogging extends Partial<TrainingParticipationReportParameterSet> { action: 'enable_presence_logging'; - /** - * default: { "after": 600, "within": 1200 } - */ - initialCheckpointDelay?: TimeRange; - /** - * default: { "after": 6300, "within": 1800 } - */ - checkpointInterval?: TimeRange; } export interface DisablePresenceLogging { diff --git a/app/src/components/CreateOrUpdateMeetingForm/CreateOrUpdateMeetingForm.tsx b/app/src/components/CreateOrUpdateMeetingForm/CreateOrUpdateMeetingForm.tsx index 4daa120069c899536bf3f6e1ee18d804d0faa9e6..8add860387a011c0eaa432fe00e28d0dd65376ac 100644 --- a/app/src/components/CreateOrUpdateMeetingForm/CreateOrUpdateMeetingForm.tsx +++ b/app/src/components/CreateOrUpdateMeetingForm/CreateOrUpdateMeetingForm.tsx @@ -18,7 +18,7 @@ import { import { Interval, addMinutes, areIntervalsOverlapping, formatRFC3339 } from 'date-fns'; import { useFormik } from 'formik'; import { FormikValues } from 'formik/dist/types'; -import { isEmpty } from 'lodash'; +import { isEmpty, isEqual } from 'lodash'; import { useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate } from 'react-router-dom'; @@ -48,6 +48,7 @@ import { CreateOrUpdateMeetingFormikValues, DashboardDateTimePicker } from './fr import EventConflictDialog from './fragments/EventConflictDialog'; import MeetingFormSwitch from './fragments/MeetingFormSwitch'; import StreamingOptions from './fragments/StreamingOptions'; +import { TrainingParticipationReportSelect } from './fragments/TrainingParticipationReportSelect/TrainingParticipationReportSelect'; interface CreateOrUpdateMeetingFormProps { existingEvent?: Event; @@ -86,6 +87,7 @@ const CreateOrUpdateMeetingForm = ({ existingEvent, onForwardButtonClick }: Crea const { data: tariff } = useGetMeTariffQuery(); const isStreamingEnabled = tariff && isFeatureEnabledPredicate('stream', tariff.modules); + const isTrainingParticipationReportEnabled = tariff?.modules.trainingParticipationReport; const navigate = useNavigate(); @@ -173,6 +175,25 @@ const CreateOrUpdateMeetingForm = ({ existingEvent, onForwardButtonClick }: Crea }), }), e2eEncryption: yup.boolean().optional(), + trainingParticipationReport: yup.object().shape({ + enabled: yup.boolean().required(), + parameter: yup.object().when('enabled', ([enabled]) => { + if (!enabled) { + return yup.object().optional(); + } + + return yup.object({ + initialCheckpointDelay: yup.object().shape({ + after: yup.number().min(0).required(), + within: yup.number().min(0).required(), + }), + checkpointInterval: yup.object().shape({ + after: yup.number().min(60).required(), + within: yup.number().min(0).required(), + }), + }); + }), + }), }); const recurrenceFrequencyOptions: Array<FrequencyOption> = [ @@ -241,6 +262,27 @@ const CreateOrUpdateMeetingForm = ({ existingEvent, onForwardButtonClick }: Crea }; }; + // const getTPRInitialValue = () => { + // console.debug('value in get initial: ', existingEvent?.trainingParticipationReport); + // return existingEvent?.trainingParticipationReport + // ? { + // enabled: true, + // parameter: existingEvent.trainingParticipationReport, + // } + // : { + // enabled: false, + // }; + // }; + + const TPRValue = existingEvent?.trainingParticipationReport + ? { + enabled: true, + parameter: existingEvent.trainingParticipationReport, + } + : { + enabled: false, + }; + // We need to pass an empty array, if we want to disable streaming while updating an event const getStreamingPayload = (values: FormikValues) => { return values.streaming.enabled ? [values.streaming.platform] : []; @@ -272,6 +314,8 @@ const CreateOrUpdateMeetingForm = ({ existingEvent, onForwardButtonClick }: Crea showMeetingDetails: getShowMeetingDetailsInitialValue(), streaming: getStreamingInitialValue(), e2eEncryption: existingEvent?.room.e2EEncryption || false, + // trainingParticipationReport: getTPRInitialValue(), + trainingParticipationReport: TPRValue, }, validationSchema, validateOnChange: false, @@ -286,6 +330,25 @@ const CreateOrUpdateMeetingForm = ({ existingEvent, onForwardButtonClick }: Crea }, }); + const createTrainingParticipationReportPayload = () => { + const localValue = formik.values.trainingParticipationReport; + //null will explicitly force the backend to remove the previous value + //undefined will ignore the field in case we do not need any update + if (!localValue.enabled) { + return existingEvent?.trainingParticipationReport ? null : undefined; + } + + if ( + existingEvent?.trainingParticipationReport && + isEqual(existingEvent.trainingParticipationReport, localValue.parameter) + ) { + return undefined; + } + + //sending the value will create/update accordingly + return localValue.parameter; + }; + const onChangeStartDate = async (date: Date | null) => { if (!date) { await formik.setFieldValue('startDate', ''); @@ -338,6 +401,7 @@ const CreateOrUpdateMeetingForm = ({ existingEvent, onForwardButtonClick }: Crea hasSharedFolder: values.sharedFolder || false, streamingTargets: getStreamingPayload(values), e2eEncryption: values.e2eEncryption || false, + trainingParticipationReport: createTrainingParticipationReportPayload(), }; if (values.recurrencePattern) { @@ -690,6 +754,8 @@ const CreateOrUpdateMeetingForm = ({ existingEvent, onForwardButtonClick }: Crea {isStreamingEnabled && <StreamingOptions formik={formik} />} + {isTrainingParticipationReportEnabled && <TrainingParticipationReportSelect formik={formik} />} + {features.e2eEncryption && ( <MeetingFormSwitch checked={formik.values.e2eEncryption} diff --git a/app/src/components/CreateOrUpdateMeetingForm/fragments/DashboardDateTimePicker.tsx b/app/src/components/CreateOrUpdateMeetingForm/fragments/DashboardDateTimePicker.tsx index b20433da9942bcfb311d55bbc8b4046dcb1ccc36..7da82f8461212cc8ed197c57198b691296b79b36 100644 --- a/app/src/components/CreateOrUpdateMeetingForm/fragments/DashboardDateTimePicker.tsx +++ b/app/src/components/CreateOrUpdateMeetingForm/fragments/DashboardDateTimePicker.tsx @@ -3,6 +3,7 @@ // SPDX-License-Identifier: EUPL-1.2 import { Stack } from '@mui/material'; import { RecurrencePattern, StreamingPlatform } from '@opentalk/rest-api-rtk-query'; +import { TrainingParticipationReportParameterSet } from '@opentalk/rest-api-rtk-query/src/types/event'; import { FormikProps } from 'formik'; import { useTranslation } from 'react-i18next'; @@ -15,6 +16,10 @@ interface Streaming { enabled: boolean; streamingTarget?: StreamingPlatform; } +interface TrainingParticipationReport { + enabled: boolean; + parameter?: TrainingParticipationReportParameterSet; +} export interface CreateOrUpdateMeetingFormikValues { title?: string; description?: string; @@ -27,6 +32,7 @@ export interface CreateOrUpdateMeetingFormikValues { isAdhoc?: boolean; sharedFolder: boolean; streaming: Streaming; + trainingParticipationReport: TrainingParticipationReport; showMeetingDetails: boolean; e2eEncryption: boolean; } diff --git a/app/src/components/CreateOrUpdateMeetingForm/fragments/TrainingParticipationReportSelect/CustomTrainingParticipationReportDialog.test.tsx b/app/src/components/CreateOrUpdateMeetingForm/fragments/TrainingParticipationReportSelect/CustomTrainingParticipationReportDialog.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f5251b828937d91a453ff4ffe09e6a8fd04b7472 --- /dev/null +++ b/app/src/components/CreateOrUpdateMeetingForm/fragments/TrainingParticipationReportSelect/CustomTrainingParticipationReportDialog.test.tsx @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: OpenTalk GmbH <mail@opentalk.eu> +// +// SPDX-License-Identifier: EUPL-1.2 +import { render, screen } from '@testing-library/react'; + +import { + CustomTrainingParticipationReportDialog, + CustomTrainingParticipationReportDialogProps, +} from './CustomTrainingParticipationReportDialog'; + +const mockDialogProps: CustomTrainingParticipationReportDialogProps = { + closeDialog: jest.fn(), + saveOption: jest.fn(), + previousOption: { + initialCheckpointDelay: { after: 60, within: 60 }, + checkpointInterval: { after: 120, within: 120 }, + }, +}; + +describe('Custom Recurrence Dialog', () => { + test('Dialog renders correctly', () => { + render(<CustomTrainingParticipationReportDialog {...mockDialogProps} />); + + expect(screen.getByTestId('custom-training-participation-report-dialog')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/CreateOrUpdateMeetingForm/fragments/TrainingParticipationReportSelect/CustomTrainingParticipationReportDialog.tsx b/app/src/components/CreateOrUpdateMeetingForm/fragments/TrainingParticipationReportSelect/CustomTrainingParticipationReportDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4b7575a6a8dbd1c5b2501614f71bf2f99a0d3164 --- /dev/null +++ b/app/src/components/CreateOrUpdateMeetingForm/fragments/TrainingParticipationReportSelect/CustomTrainingParticipationReportDialog.tsx @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: OpenTalk GmbH <mail@opentalk.eu> +// +// SPDX-License-Identifier: EUPL-1.2 +import { + Button, + Dialog, + DialogActions, + DialogContent as MuiDialogContent, + DialogProps, + DialogTitle, + Stack, + styled, + Typography, +} from '@mui/material'; +import { TrainingParticipationReportParameterSet } from '@opentalk/rest-api-rtk-query/src/types/event'; +import { minutesToSeconds, secondsToMinutes } from 'date-fns'; +import { useFormik } from 'formik'; +import { useTranslation } from 'react-i18next'; + +import { CommonTextField, ErrorFormMessage } from '../../../../commonComponents'; +import { formikNumberFieldProps } from '../../../../utils/formikUtils'; +import yup from '../../../../utils/yupUtils'; + +export interface CustomTrainingParticipationReportDialogProps extends Omit<DialogProps, 'open'> { + closeDialog: () => void; + previousOption: TrainingParticipationReportParameterSet; + saveOption: (option: TrainingParticipationReportParameterSet) => void; +} + +const CUSTOM_TRAINING_PARTICIPATION_REPORT_DIALOG_LABEL_ID = 'custom-training-participation-report-dialog-title'; + +const NumberInput = styled(CommonTextField)({ + maxWidth: '4rem', + '& input': { + paddingRight: 0, + textAlign: 'center', + }, +}); + +const DialogContent = styled(MuiDialogContent)(({ theme }) => ({ + [theme.breakpoints.up('md')]: { + minWidth: theme.typography.pxToRem(500), + }, +})); + +export const CustomTrainingParticipationReportDialog = ({ + previousOption, + closeDialog, + saveOption, + ...props +}: CustomTrainingParticipationReportDialogProps) => { + const { t } = useTranslation(); + + const validationSchema = yup.object({ + initialCheckpointDelay: yup.object().shape({ + from: yup.number().min(0).required(), + to: yup.number().when('from', ([from]) => { + return yup.number().min(from, t('dashboard-custom-training-participation-report-dialog-error', { min: from })); + }), + }), + checkpointInterval: yup.object().shape({ + from: yup.number().min(1).required(), + to: yup.number().when('from', ([from]) => { + return yup.number().min(from, t('dashboard-custom-training-participation-report-dialog-error', { min: from })); + }), + }), + }); + + const convertedPreviousOption = { + initialCheckpointDelay: { + from: secondsToMinutes(previousOption.initialCheckpointDelay.after), + to: secondsToMinutes(previousOption.initialCheckpointDelay.after + previousOption.initialCheckpointDelay.within), + }, + checkpointInterval: { + from: secondsToMinutes(previousOption.checkpointInterval.after), + to: secondsToMinutes(previousOption.checkpointInterval.after + previousOption.checkpointInterval.within), + }, + }; + + const formik = useFormik({ + initialValues: convertedPreviousOption, + validationSchema, + validateOnChange: true, + validateOnBlur: false, + onSubmit: ({ initialCheckpointDelay, checkpointInterval }) => { + const initialCheckpointDelayAfter = minutesToSeconds(initialCheckpointDelay.from); + const initialCheckpointDelayWithin = minutesToSeconds(initialCheckpointDelay.to - initialCheckpointDelay.from); + const checkpointIntervalAfter = minutesToSeconds(checkpointInterval.from); + const checkpointIntervalWithin = minutesToSeconds(checkpointInterval.to - checkpointInterval.from); + + saveOption({ + initialCheckpointDelay: { after: initialCheckpointDelayAfter, within: initialCheckpointDelayWithin }, + checkpointInterval: { after: checkpointIntervalAfter, within: checkpointIntervalWithin }, + }); + + closeDialog(); + }, + }); + + const closeWithReset = () => { + closeDialog(); + formik.resetForm(); + }; + + return ( + <Dialog + {...props} + open + onClose={closeWithReset} + aria-labelledby={CUSTOM_TRAINING_PARTICIPATION_REPORT_DIALOG_LABEL_ID} + data-testid="custom-training-participation-report-dialog" + > + <DialogTitle id={CUSTOM_TRAINING_PARTICIPATION_REPORT_DIALOG_LABEL_ID}> + {t('dashboard-custom-training-participation-report-dialog-title')} + </DialogTitle> + <DialogContent> + <Stack marginBottom={2}> + <Typography>{t('dashboard-custom-training-participation-report-dialog-initial-timeout')}</Typography> + <Stack direction="row" spacing={1} alignItems="center"> + <NumberInput + {...formikNumberFieldProps('initialCheckpointDelay.from', formik)} + type="number" + slotProps={{ htmlInput: { min: 0 } }} + /> + <Typography>{t('dashboard-custom-training-participation-report-dialog-to')}</Typography> + <NumberInput + {...formikNumberFieldProps('initialCheckpointDelay.to', formik)} + type="number" + slotProps={{ htmlInput: { min: formik.values.initialCheckpointDelay.from } }} + /> + <Typography>{t('dashboard-custom-training-participation-report-dialog-minutes')}</Typography> + </Stack> + {formik.errors.initialCheckpointDelay?.to && ( + <ErrorFormMessage helperText={formik.errors.initialCheckpointDelay.to} /> + )} + </Stack> + <Stack> + <Typography>{t('dashboard-custom-training-participation-report-dialog-interval-duration')}</Typography> + <Stack direction="row" spacing={1} alignItems="center"> + <NumberInput + {...formikNumberFieldProps('checkpointInterval.from', formik)} + type="number" + slotProps={{ htmlInput: { min: 1 } }} + /> + <Typography>{t('dashboard-custom-training-participation-report-dialog-to')}</Typography> + <NumberInput + {...formikNumberFieldProps('checkpointInterval.to', formik)} + type="number" + slotProps={{ htmlInput: { min: formik.values.checkpointInterval.from } }} + /> + <Typography>{t('dashboard-custom-training-participation-report-dialog-minutes')}</Typography> + </Stack> + {formik.errors.checkpointInterval?.to && ( + <ErrorFormMessage helperText={formik.errors.checkpointInterval.to} /> + )} + </Stack> + </DialogContent> + <DialogActions> + <Button variant="contained" color="secondary" onClick={closeWithReset}> + {t('global-close')} + </Button> + <Button variant="contained" onClick={() => formik.handleSubmit()}> + {t('global-save')} + </Button> + </DialogActions> + </Dialog> + ); +}; diff --git a/app/src/components/CreateOrUpdateMeetingForm/fragments/TrainingParticipationReportSelect/TrainingParticipationReportSelect.test.tsx b/app/src/components/CreateOrUpdateMeetingForm/fragments/TrainingParticipationReportSelect/TrainingParticipationReportSelect.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..783303a36b26d831d147e3583ac5c4d0ce280c66 --- /dev/null +++ b/app/src/components/CreateOrUpdateMeetingForm/fragments/TrainingParticipationReportSelect/TrainingParticipationReportSelect.test.tsx @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: OpenTalk GmbH <mail@opentalk.eu> +// +// SPDX-License-Identifier: EUPL-1.2 +import { RecurrencePattern } from '@opentalk/rest-api-rtk-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Formik } from 'formik'; + +import { TrainingParticipationReportSelect } from './TrainingParticipationReportSelect'; + +const initialValues = { + trainingParticipationReport: { + enabled: true, + }, + waitingRoom: false, + isTimeDependent: false, + startDate: '', + endDate: '', + recurrencePattern: '' as RecurrencePattern, + sharedFolder: false, + streaming: { + enabled: false, + }, + showMeetingDetails: false, + e2eEncryption: false, +}; + +describe('Training participation report select', () => { + test('Select is not rendered when not enabled', async () => { + render( + <Formik initialValues={{ ...initialValues, trainingParticipationReport: { enabled: false } }} onSubmit={() => {}}> + {(formikProps) => <TrainingParticipationReportSelect formik={formikProps} />} + </Formik> + ); + + const selectButton = screen.queryByRole('combobox'); + expect(selectButton).not.toBeInTheDocument(); + }); + + test('Select is rendered and usable when enabled', () => { + render( + <Formik initialValues={initialValues} onSubmit={() => {}}> + {(formikProps) => <TrainingParticipationReportSelect formik={formikProps} />} + </Formik> + ); + + const selectButton = screen.getByRole('combobox'); + expect(selectButton).toBeInTheDocument(); + + selectButton && fireEvent.mouseDown(selectButton); + + const listbox = screen.getByRole('listbox'); + expect(listbox).toBeInTheDocument(); + + const sixtyMinOption = screen.getByRole('option', { + name: 'dashboard-meeting-training-participation-report-option-every-sixty-min', + }); + + fireEvent.click(sixtyMinOption); + + const select = screen.getByTestId('parameter-select').textContent?.replace(/\u200B/g, ''); + expect(select).toBe('dashboard-meeting-training-participation-report-option-every-sixty-min'); + }); +}); diff --git a/app/src/components/CreateOrUpdateMeetingForm/fragments/TrainingParticipationReportSelect/TrainingParticipationReportSelect.tsx b/app/src/components/CreateOrUpdateMeetingForm/fragments/TrainingParticipationReportSelect/TrainingParticipationReportSelect.tsx new file mode 100644 index 0000000000000000000000000000000000000000..963abc09d7f17f2dbc314afdbb30fa4788be8e94 --- /dev/null +++ b/app/src/components/CreateOrUpdateMeetingForm/fragments/TrainingParticipationReportSelect/TrainingParticipationReportSelect.tsx @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: OpenTalk GmbH <mail@opentalk.eu> +// +// SPDX-License-Identifier: EUPL-1.2 +import { Collapse, MenuItem, Stack } from '@mui/material'; +import { TrainingParticipationReportParameterSet } from '@opentalk/rest-api-rtk-query/src/types/event'; +import { FormikProps } from 'formik'; +import { isEqual } from 'lodash'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { CommonTextField } from '../../../../commonComponents'; +import { formikMinimalProps } from '../../../../utils/formikUtils'; +import { CreateOrUpdateMeetingFormikValues } from '../DashboardDateTimePicker'; +import MeetingFormSwitch from '../MeetingFormSwitch'; +import { CustomTrainingParticipationReportDialog } from './CustomTrainingParticipationReportDialog'; + +interface TrainingParticipationReportSelectProps { + formik: FormikProps<CreateOrUpdateMeetingFormikValues>; +} + +enum TrainingParticipationReportConfigOptions { + EveryThirtyMin = 'every-thirty-min', + EverySixtyMin = 'every-sixty-min', + ThirtyToSixtyMin = 'thirty-to-sixty-min', + NinetyToOneHundredTwentyMin = 'ninety-to-hundred-twenty-min', + Custom = 'custom', +} + +export const TrainingParticipationReportSelect = ({ formik }: TrainingParticipationReportSelectProps) => { + const { t } = useTranslation(); + const { enabled, parameter } = formik.values.trainingParticipationReport; + const [isCustomDialogOpen, setIsCustomDialogOpen] = useState(false); + const [customOption, setCustomOption] = useState({ + initialCheckpointDelay: { after: 600, within: 1200 }, + checkpointInterval: { after: 2700, within: 900 }, + }); + + const options: Record<TrainingParticipationReportConfigOptions, TrainingParticipationReportParameterSet> = { + [TrainingParticipationReportConfigOptions.EveryThirtyMin]: { + initialCheckpointDelay: { after: 1800, within: 0 }, + checkpointInterval: { after: 1800, within: 0 }, + }, + [TrainingParticipationReportConfigOptions.EverySixtyMin]: { + initialCheckpointDelay: { after: 3600, within: 0 }, + checkpointInterval: { after: 3600, within: 0 }, + }, + [TrainingParticipationReportConfigOptions.ThirtyToSixtyMin]: { + initialCheckpointDelay: { after: 1800, within: 1800 }, + checkpointInterval: { after: 1800, within: 1800 }, + }, + [TrainingParticipationReportConfigOptions.NinetyToOneHundredTwentyMin]: { + initialCheckpointDelay: { after: 5400, within: 1800 }, + checkpointInterval: { after: 5400, within: 1800 }, + }, + [TrainingParticipationReportConfigOptions.Custom]: customOption, + }; + + const [selectedOption, setSelectedOption] = useState(TrainingParticipationReportConfigOptions.EveryThirtyMin); + + useEffect(() => { + if (!enabled) { + return; + } + + if (parameter) { + const existingOptionKey = (Object.keys(options) as Array<TrainingParticipationReportConfigOptions>).find( + (option) => isEqual(parameter, options[option]) + ); + if (existingOptionKey) { + setSelectedOption(existingOptionKey); + } else { + setSelectedOption(TrainingParticipationReportConfigOptions.Custom); + setCustomOption(parameter); + } + return; + } + + formik.setFieldValue( + 'trainingParticipationReport.parameter', + options[TrainingParticipationReportConfigOptions.EveryThirtyMin] + ); + }, [enabled]); + + const handleSelect = (option: TrainingParticipationReportConfigOptions) => { + if (option === TrainingParticipationReportConfigOptions.Custom) { + setIsCustomDialogOpen(true); + } + + setSelectedOption(option); + formik.setFieldValue('trainingParticipationReport.parameter', options[option]); + }; + + const handleSave = (value: TrainingParticipationReportParameterSet) => { + setCustomOption(value); + formik.setFieldValue('trainingParticipationReport.parameter', value); + setIsCustomDialogOpen(false); + }; + + return ( + <Stack spacing={2}> + <MeetingFormSwitch + switchProps={formikMinimalProps('trainingParticipationReport.enabled', formik)} + checked={enabled} + switchValueLabel={t('dashboard-meeting-training-participation-report-switch')} + /> + <Collapse orientation="vertical" in={enabled} unmountOnExit mountOnEnter> + <CommonTextField data-testid="parameter-select" select value={selectedOption}> + {Object.keys(options).map((option) => ( + <MenuItem + key={option} + value={option} + onClick={() => handleSelect(option as TrainingParticipationReportConfigOptions)} + > + {t(`dashboard-meeting-training-participation-report-option-${option}`)} + </MenuItem> + ))} + </CommonTextField> + </Collapse> + + {isCustomDialogOpen && ( + <CustomTrainingParticipationReportDialog + closeDialog={() => setIsCustomDialogOpen(false)} + previousOption={customOption} + saveOption={handleSave} + /> + )} + </Stack> + ); +}; diff --git a/app/src/utils/formikUtils.ts b/app/src/utils/formikUtils.ts index c4391266757dd1239258a9aa6c9f90d51ff15741..3b4f19424bb90a2dd86975c281f12bd0eba238eb 100644 --- a/app/src/utils/formikUtils.ts +++ b/app/src/utils/formikUtils.ts @@ -171,3 +171,33 @@ export function formikDurationFieldProps<Values>( helperText: (hasError && (errorMessage as string)) || undefined, }; } + +export function formikNumberFieldProps<Values>( + fieldName: string, + formik: FormikProps<Values>, + /** + * Duration value in minutes + * + * Default: 1 + */ + defaultValue?: number +) { + const { values, handleBlur, handleChange } = formik; + + const onChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const nextValue = parseInt(event.target.value); + if (Number.isNaN(nextValue)) { + handleChange({ ...event, target: { ...event.target, value: defaultValue } }); + return; + } + + handleChange(event); + }; + + return { + name: fieldName, + onChange: onChange, + onBlur: handleBlur, + value: get(values, fieldName, defaultValue ?? 1)?.toString(), + }; +} diff --git a/packages/rtk-rest-api/src/types/event.ts b/packages/rtk-rest-api/src/types/event.ts index a669e8b148a42b6f17af05a54fb33056a351a610..b000bae64854fdcc6a8a53f94c32af81c8f7fb13 100644 --- a/packages/rtk-rest-api/src/types/event.ts +++ b/packages/rtk-rest-api/src/types/event.ts @@ -34,6 +34,27 @@ export interface SharedFolderData { readWrite?: SharedFolderCredentials; } +export interface TimeRange { + /** + * The earliest number of seconds after which the checkpoint can be created. Must be 0 or greater. + */ + after: number; + /** + * The number of seconds within which the checkpoint can be created after the after value. Must be 0 or greater. + */ + within: number; +} +export interface TrainingParticipationReportParameterSet { + /** + * default: { "after": 600, "within": 1200 } + */ + initialCheckpointDelay: TimeRange; + /** + * default: { "after": 6300, "within": 1800 } + */ + checkpointInterval: TimeRange; +} + /** * EventRoomInfo in an Event object */ @@ -83,6 +104,7 @@ export interface CreateBaseEventPayload { showMeetingDetails?: boolean; hasSharedFolder?: boolean; streamingTargets?: Array<StreamingPlatform>; + trainingParticipationReport?: TrainingParticipationReportParameterSet; } /** @@ -156,6 +178,7 @@ export interface UpdateEventPayload { showMeetingDetails?: boolean; hasSharedFolder?: boolean; streamingTargets?: Array<StreamingPlatform>; + trainingParticipationReport?: TrainingParticipationReportParameterSet | null; } /** @@ -228,6 +251,7 @@ interface AbstractEvent extends BaseEvent { sharedFolder?: SharedFolderData; showMeetingDetails?: boolean; streamingTargets?: Array<StreamingPlatform>; + trainingParticipationReport?: TrainingParticipationReportParameterSet; } /**