diff --git a/.changeset/ten-stingrays-eat.md b/.changeset/ten-stingrays-eat.md new file mode 100644 index 000000000000..8a8988bd77c2 --- /dev/null +++ b/.changeset/ten-stingrays-eat.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +"@rocket.chat/i18n": minor +--- + +Added the possibility to choose the time unit (days, hours, minutes) to the global retention policy settings diff --git a/apps/meteor/app/lib/server/methods/saveSettings.ts b/apps/meteor/app/lib/server/methods/saveSettings.ts index 93e884c9f77e..7c6a2a801361 100644 --- a/apps/meteor/app/lib/server/methods/saveSettings.ts +++ b/apps/meteor/app/lib/server/methods/saveSettings.ts @@ -82,6 +82,7 @@ Meteor.methods({ case 'boolean': check(value, Boolean); break; + case 'timespan': case 'int': check(value, Number); break; diff --git a/apps/meteor/app/retention-policy/server/cronPruneMessages.ts b/apps/meteor/app/retention-policy/server/cronPruneMessages.ts index fb0e691abd69..d12b734c8906 100644 --- a/apps/meteor/app/retention-policy/server/cronPruneMessages.ts +++ b/apps/meteor/app/retention-policy/server/cronPruneMessages.ts @@ -29,7 +29,7 @@ async function job(): Promise { // get all rooms with default values for await (const type of types) { const maxAge = maxTimes[type] || 0; - const latest = new Date(now.getTime() - toDays(maxAge)); + const latest = new Date(now.getTime() - maxAge); const rooms = await Rooms.find( { @@ -107,9 +107,9 @@ settings.watchMultiple( 'RetentionPolicy_AppliesToChannels', 'RetentionPolicy_AppliesToGroups', 'RetentionPolicy_AppliesToDMs', - 'RetentionPolicy_MaxAge_Channels', - 'RetentionPolicy_MaxAge_Groups', - 'RetentionPolicy_MaxAge_DMs', + 'RetentionPolicy_TTL_Channels', + 'RetentionPolicy_TTL_Groups', + 'RetentionPolicy_TTL_DMs', 'RetentionPolicy_Advanced_Precision', 'RetentionPolicy_Advanced_Precision_Cron', 'RetentionPolicy_Precision', @@ -132,9 +132,9 @@ settings.watchMultiple( types.push('d'); } - maxTimes.c = settings.get('RetentionPolicy_MaxAge_Channels'); - maxTimes.p = settings.get('RetentionPolicy_MaxAge_Groups'); - maxTimes.d = settings.get('RetentionPolicy_MaxAge_DMs'); + maxTimes.c = settings.get('RetentionPolicy_TTL_Channels'); + maxTimes.p = settings.get('RetentionPolicy_TTL_Groups'); + maxTimes.d = settings.get('RetentionPolicy_TTL_DMs'); const precision = (settings.get('RetentionPolicy_Advanced_Precision') && settings.get('RetentionPolicy_Advanced_Precision_Cron')) || diff --git a/apps/meteor/client/components/InfoPanel/RetentionPolicyCallout.tsx b/apps/meteor/client/components/InfoPanel/RetentionPolicyCallout.tsx index 06f6ed133dc3..be513e477cd9 100644 --- a/apps/meteor/client/components/InfoPanel/RetentionPolicyCallout.tsx +++ b/apps/meteor/client/components/InfoPanel/RetentionPolicyCallout.tsx @@ -4,7 +4,6 @@ import type { FC } from 'react'; import React from 'react'; import { useFormattedRelativeTime } from '../../hooks/useFormattedRelativeTime'; -import { getMaxAgeInMS } from '../../views/room/hooks/useRetentionPolicy'; type RetentionPolicyCalloutProps = { filesOnly: boolean; @@ -14,7 +13,7 @@ type RetentionPolicyCalloutProps = { const RetentionPolicyCallout: FC = ({ filesOnly, excludePinned, maxAge }) => { const t = useTranslation(); - const time = useFormattedRelativeTime(getMaxAgeInMS(maxAge)); + const time = useFormattedRelativeTime(maxAge); return ( diff --git a/apps/meteor/client/lib/convertTimeUnit.spec.ts b/apps/meteor/client/lib/convertTimeUnit.spec.ts new file mode 100644 index 000000000000..e781d2d953a0 --- /dev/null +++ b/apps/meteor/client/lib/convertTimeUnit.spec.ts @@ -0,0 +1,65 @@ +import { TIMEUNIT, timeUnitToMs, msToTimeUnit } from './convertTimeUnit'; + +describe('timeUnitToMs function', () => { + it('should correctly convert days to milliseconds', () => { + expect(timeUnitToMs(TIMEUNIT.days, 1)).toBe(86400000); + expect(timeUnitToMs(TIMEUNIT.days, 2)).toBe(172800000); + expect(timeUnitToMs(TIMEUNIT.days, 0.5)).toBe(43200000); + }); + + it('should correctly convert hours to milliseconds', () => { + expect(timeUnitToMs(TIMEUNIT.hours, 1)).toBe(3600000); + expect(timeUnitToMs(TIMEUNIT.hours, 2)).toBe(7200000); + expect(timeUnitToMs(TIMEUNIT.hours, 0.5)).toBe(1800000); + }); + + it('should correctly convert minutes to milliseconds', () => { + expect(timeUnitToMs(TIMEUNIT.minutes, 1)).toBe(60000); + expect(timeUnitToMs(TIMEUNIT.minutes, 2)).toBe(120000); + expect(timeUnitToMs(TIMEUNIT.minutes, 0.5)).toBe(30000); + }); + + it('should throw an error for invalid time units', () => { + expect(() => timeUnitToMs('invalidUnit' as TIMEUNIT, 1)).toThrow('timeUnitToMs - invalid time unit'); + }); + + it('should throw an error for invalid timespan', () => { + const errorMessage = 'timeUnitToMs - invalid timespan'; + expect(() => timeUnitToMs(TIMEUNIT.days, NaN)).toThrow(errorMessage); + expect(() => timeUnitToMs(TIMEUNIT.days, Infinity)).toThrow(errorMessage); + expect(() => timeUnitToMs(TIMEUNIT.days, -Infinity)).toThrow(errorMessage); + expect(() => timeUnitToMs(TIMEUNIT.days, -1)).toThrow(errorMessage); + }); +}); + +describe('msToTimeUnit function', () => { + it('should correctly convert milliseconds to days', () => { + expect(msToTimeUnit(TIMEUNIT.days, 86400000)).toBe(1); // 1 day + expect(msToTimeUnit(TIMEUNIT.days, 172800000)).toBe(2); // 2 days + expect(msToTimeUnit(TIMEUNIT.days, 43200000)).toBe(0.5); // .5 days + }); + + it('should correctly convert milliseconds to hours', () => { + expect(msToTimeUnit(TIMEUNIT.hours, 3600000)).toBe(1); // 1 hour + expect(msToTimeUnit(TIMEUNIT.hours, 7200000)).toBe(2); // 2 hours + expect(msToTimeUnit(TIMEUNIT.hours, 1800000)).toBe(0.5); // .5 hours + }); + + it('should correctly convert milliseconds to minutes', () => { + expect(msToTimeUnit(TIMEUNIT.minutes, 60000)).toBe(1); // 1 min + expect(msToTimeUnit(TIMEUNIT.minutes, 120000)).toBe(2); // 2 min + expect(msToTimeUnit(TIMEUNIT.minutes, 30000)).toBe(0.5); // .5 min + }); + + it('should throw an error for invalid time units', () => { + expect(() => msToTimeUnit('invalidUnit' as TIMEUNIT, 1)).toThrow('msToTimeUnit - invalid time unit'); + }); + + it('should throw an error for invalid timespan', () => { + const errorMessage = 'msToTimeUnit - invalid timespan'; + expect(() => msToTimeUnit(TIMEUNIT.days, NaN)).toThrow(errorMessage); + expect(() => msToTimeUnit(TIMEUNIT.days, Infinity)).toThrow(errorMessage); + expect(() => msToTimeUnit(TIMEUNIT.days, -Infinity)).toThrow(errorMessage); + expect(() => msToTimeUnit(TIMEUNIT.days, -1)).toThrow(errorMessage); + }); +}); diff --git a/apps/meteor/client/lib/convertTimeUnit.ts b/apps/meteor/client/lib/convertTimeUnit.ts new file mode 100644 index 000000000000..f87f595b6902 --- /dev/null +++ b/apps/meteor/client/lib/convertTimeUnit.ts @@ -0,0 +1,58 @@ +export enum TIMEUNIT { + days = 'days', + hours = 'hours', + minutes = 'minutes', +} + +const isValidTimespan = (timespan: number): boolean => { + if (Number.isNaN(timespan)) { + return false; + } + + if (!Number.isFinite(timespan)) { + return false; + } + + if (timespan < 0) { + return false; + } + + return true; +}; + +export const timeUnitToMs = (unit: TIMEUNIT, timespan: number) => { + if (!isValidTimespan(timespan)) { + throw new Error('timeUnitToMs - invalid timespan'); + } + + switch (unit) { + case TIMEUNIT.days: + return timespan * 24 * 60 * 60 * 1000; + + case TIMEUNIT.hours: + return timespan * 60 * 60 * 1000; + + case TIMEUNIT.minutes: + return timespan * 60 * 1000; + + default: + throw new Error('timeUnitToMs - invalid time unit'); + } +}; + +export const msToTimeUnit = (unit: TIMEUNIT, timespan: number) => { + if (!isValidTimespan(timespan)) { + throw new Error('msToTimeUnit - invalid timespan'); + } + + switch (unit) { + case TIMEUNIT.days: + return timespan / 24 / 60 / 60 / 1000; + case TIMEUNIT.hours: + return timespan / 60 / 60 / 1000; + case TIMEUNIT.minutes: + return timespan / 60 / 1000; + default: + throw new Error('msToTimeUnit - invalid time unit'); + } +}; diff --git a/apps/meteor/client/omnichannel/priorities/PriorityEditForm.tsx b/apps/meteor/client/omnichannel/priorities/PriorityEditForm.tsx index 38157e435b8c..d67f637a2b4e 100644 --- a/apps/meteor/client/omnichannel/priorities/PriorityEditForm.tsx +++ b/apps/meteor/client/omnichannel/priorities/PriorityEditForm.tsx @@ -81,6 +81,7 @@ const PriorityEditForm = ({ data, onSave, onCancel }: PriorityEditFormProps): Re render={({ field: { value, onChange } }): ReactElement => ( > = { @@ -39,6 +40,7 @@ const inputsByType: Record> = { roomPick: RoomPickSettingInput, timezone: SelectTimezoneSettingInput, lookup: LookupSettingInput, + timespan: TimespanSettingInput, date: GenericSettingInput, // @todo: implement group: GenericSettingInput, // @todo: implement }; @@ -46,6 +48,7 @@ const inputsByType: Record> = { type MemoizedSettingProps = { _id?: string; type: ISettingBase['type']; + packageValue: ISettingBase['packageValue']; hint?: ReactNode; callout?: ReactNode; value?: SettingValue; diff --git a/apps/meteor/client/views/admin/settings/Setting.stories.tsx b/apps/meteor/client/views/admin/settings/Setting.stories.tsx index e80db37d821e..18ff3801dbc9 100644 --- a/apps/meteor/client/views/admin/settings/Setting.stories.tsx +++ b/apps/meteor/client/views/admin/settings/Setting.stories.tsx @@ -42,17 +42,17 @@ WithCallout.args = { export const types = () => ( - - - - - - - - - - - + + + + + + + + + + + ); diff --git a/apps/meteor/client/views/admin/settings/inputs/TimespanSettingInput.spec.tsx b/apps/meteor/client/views/admin/settings/inputs/TimespanSettingInput.spec.tsx new file mode 100644 index 000000000000..cd345b0f1ede --- /dev/null +++ b/apps/meteor/client/views/admin/settings/inputs/TimespanSettingInput.spec.tsx @@ -0,0 +1,157 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { TIMEUNIT } from '../../../../lib/convertTimeUnit'; +import { default as TimespanSettingInput, getHighestTimeUnit } from './TimespanSettingInput'; + +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +describe('getHighestTimeUnit function', () => { + it('should return minutes if milliseconds cannot be evenly divided into hours or days', () => { + expect(getHighestTimeUnit(900000)).toBe(TIMEUNIT.minutes); // 15 min + expect(getHighestTimeUnit(2100000)).toBe(TIMEUNIT.minutes); // 35 min + expect(getHighestTimeUnit(3660000)).toBe(TIMEUNIT.minutes); // 61 minutes + expect(getHighestTimeUnit(86460000)).toBe(TIMEUNIT.minutes); // 1441 minutes + }); + + it('should return hours if milliseconds can be evenly divided into hours but not days', () => { + expect(getHighestTimeUnit(3600000)).toBe(TIMEUNIT.hours); // 1 hour + expect(getHighestTimeUnit(7200000)).toBe(TIMEUNIT.hours); // 2 hours + expect(getHighestTimeUnit(90000000)).toBe(TIMEUNIT.hours); // 25 hours + }); + + it('should return days if milliseconds can be evenly divided into days', () => { + expect(getHighestTimeUnit(86400000)).toBe(TIMEUNIT.days); // 1 day + expect(getHighestTimeUnit(172800000)).toBe(TIMEUNIT.days); // 2 days + expect(getHighestTimeUnit(604800000)).toBe(TIMEUNIT.days); // 7 days + }); +}); + +describe('TimespanSettingInput component', () => { + const onChangeValueMock = jest.fn(); + const onResetButtonClickMock = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call onChangeValue with the correct value when inputting a value and changing time unit', () => { + render( + , + ); + + const numberInput = screen.getByRole('spinbutton'); + userEvent.clear(numberInput); // Change value to 2 + userEvent.type(numberInput, '2'); + + expect(onChangeValueMock).toHaveBeenCalledWith(2 * 24 * 60 * 60 * 1000); // 2 days in milliseconds + }); + + it('should update value to minutes when changing time unit to minutes', () => { + render( + , + ); + + const selectInput = screen.getByRole('button', { name: 'hours' }); + userEvent.click(selectInput); + const minutesOption = screen.getByRole('option', { name: 'minutes' }); + userEvent.click(minutesOption); + + expect(onChangeValueMock).toHaveBeenCalledWith(60000); // 1 min in milliseconds + + expect(screen.getByDisplayValue('1')).toBeTruthy(); + }); + + it('should update value to hours when changing time unit to hours', () => { + render( + , + ); + + const selectInput = screen.getByRole('button', { name: 'days' }); + userEvent.click(selectInput); + const hoursOption = screen.getByRole('option', { name: 'hours' }); + userEvent.click(hoursOption); + + expect(onChangeValueMock).toHaveBeenCalledWith(3600000); // 1 hour in milliseconds + + expect(screen.getByDisplayValue('1')).toBeTruthy(); + }); + + it('should update value to days when changing time unit to days', () => { + render( + , + ); + + const selectInput = screen.getByRole('button', { name: 'hours' }); + userEvent.click(selectInput); + const daysOption = screen.getByRole('option', { name: 'days' }); + userEvent.click(daysOption); + + expect(onChangeValueMock).toHaveBeenCalledWith(1036800000); // 12 days in milliseconds + + expect(screen.getByDisplayValue('12')).toBeTruthy(); + }); + + it('should call onResetButtonClick when reset button is clicked', () => { + render( + , + ); + + const resetButton = screen.getByTitle('Reset'); + userEvent.click(resetButton); + + expect(onResetButtonClickMock).toHaveBeenCalled(); + expect(screen.getByDisplayValue('30')).toBeTruthy(); + }); +}); diff --git a/apps/meteor/client/views/admin/settings/inputs/TimespanSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/TimespanSettingInput.tsx new file mode 100644 index 000000000000..14191d133c75 --- /dev/null +++ b/apps/meteor/client/views/admin/settings/inputs/TimespanSettingInput.tsx @@ -0,0 +1,112 @@ +import { Field, FieldLabel, FieldRow, InputBox, Select } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { FormEventHandler, ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; + +import { TIMEUNIT, timeUnitToMs, msToTimeUnit } from '../../../../lib/convertTimeUnit'; +import ResetSettingButton from '../ResetSettingButton'; +import type { SettingInputProps } from './types'; + +type TimespanSettingInputProps = SettingInputProps & { + value: string; +}; + +export const getHighestTimeUnit = (value: number): TIMEUNIT => { + const minutes = msToTimeUnit(TIMEUNIT.minutes, value); + if (minutes % 60 !== 0) { + return TIMEUNIT.minutes; + } + + const hours = msToTimeUnit(TIMEUNIT.hours, value); + if (hours % 24 !== 0) { + return TIMEUNIT.hours; + } + + return TIMEUNIT.days; +}; + +const sanitizeInputValue = (value: number) => { + if (!value) { + return 0; + } + + const sanitizedValue = Math.max(0, value).toFixed(0); + + return Number(sanitizedValue); +}; + +function TimespanSettingInput({ + _id, + label, + value, + placeholder, + readonly, + autocomplete, + disabled, + required, + onChangeValue, + hasResetButton, + onResetButtonClick, + packageValue, +}: TimespanSettingInputProps): ReactElement { + const t = useTranslation(); + + const [timeUnit, setTimeUnit] = useState(getHighestTimeUnit(Number(value))); + const [internalValue, setInternalValue] = useState(msToTimeUnit(timeUnit, Number(value))); + + const handleChange: FormEventHandler = (event) => { + const newValue = sanitizeInputValue(Number(event.currentTarget.value)); + + onChangeValue?.(timeUnitToMs(timeUnit, newValue)); + + setInternalValue(newValue); + }; + + const handleChangeTimeUnit = (nextTimeUnit: string | number) => { + if (typeof nextTimeUnit !== 'string') { + return; + } + onChangeValue?.(timeUnitToMs(nextTimeUnit as TIMEUNIT, internalValue)); + setTimeUnit(nextTimeUnit as TIMEUNIT); + }; + + const timeUnitOptions = useMemo(() => { + return Object.entries(TIMEUNIT).map(([label, value]) => [value, t.has(label) ? t(label) : label]); // todo translate + }, [t]); + + const handleResetButtonClick = () => { + onResetButtonClick?.(); + const newTimeUnit = getHighestTimeUnit(Number(packageValue)); + setTimeUnit(newTimeUnit); + setInternalValue(msToTimeUnit(newTimeUnit, Number(packageValue))); + }; + + return ( + + + + {label} + + {hasResetButton && } + + + + + +