Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow different time units for retention policy #32425

Merged
merged 36 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
7ee66fb
Settings and translations
gabriellsh May 13, 2024
1c7467c
Timespan input
gabriellsh May 13, 2024
8d55014
cron
gabriellsh May 13, 2024
183357e
add cs
gabriellsh May 13, 2024
62e38fc
Merge branch 'develop' into imp/retention
gabriellsh May 13, 2024
d7ae3bd
add unit tests
gabriellsh May 15, 2024
ee32132
clean migration
KevLehman May 16, 2024
430e960
Review and QA
gabriellsh May 16, 2024
6d13c8f
review and QA 2
gabriellsh May 17, 2024
d24ba1b
Fix other places that use the setting
gabriellsh May 20, 2024
ed03337
fix migration check
gabriellsh May 20, 2024
db22715
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into imp/…
gabriellsh May 28, 2024
01aeaa9
fix ts and unit
gabriellsh May 28, 2024
4e58642
fix typecheck
gabriellsh May 28, 2024
5d4908f
typecheck again
gabriellsh May 28, 2024
b7ae52c
undo setting
gabriellsh May 31, 2024
d78832a
unbreak-it
gabriellsh May 31, 2024
e9a586b
update only once
gabriellsh May 31, 2024
3bab15c
remove migration
gabriellsh May 31, 2024
44e84eb
fix tests
gabriellsh Jun 3, 2024
79bcef3
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into imp/…
gabriellsh Jun 3, 2024
9cb434a
fix edit room
gabriellsh Jun 3, 2024
971b318
fix test
gabriellsh Jun 3, 2024
dc5a9db
Update apps/meteor/server/startup/migrations/xrun.ts
gabriellsh Jun 7, 2024
078197a
fix xrun
gabriellsh Jun 7, 2024
53a3393
Merge branch 'develop' into imp/retention
gabriellsh Jun 10, 2024
48e4c0c
remove the balaca
gabriellsh Jun 11, 2024
b7039a5
update translation
gabriellsh Jun 11, 2024
3b01f2d
Review
gabriellsh Jun 11, 2024
c5f280e
fix lint
gabriellsh Jun 12, 2024
457e539
Merge branch 'develop' into imp/retention
gabriellsh Jun 13, 2024
0cdccd8
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into imp/…
gabriellsh Jun 18, 2024
bf4db11
Merge branch 'develop' into imp/retention
gabriellsh Jun 19, 2024
97b16db
Merge branch 'develop' into imp/retention
gabriellsh Jun 20, 2024
533f8bf
Merge branch 'develop' into imp/retention
ggazzo Jun 20, 2024
02b5ddf
Merge branch 'develop' into imp/retention
ggazzo Jun 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/ten-stingrays-eat.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions apps/meteor/app/lib/server/methods/saveSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Meteor.methods<ServerMethods>({
case 'boolean':
check(value, Boolean);
break;
case 'timespan':
case 'int':
check(value, Number);
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ async function job(): Promise<void> {
// 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(
{
Expand Down
2 changes: 2 additions & 0 deletions apps/meteor/client/views/admin/settings/MemoizedSetting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import RoomPickSettingInput from './inputs/RoomPickSettingInput';
import SelectSettingInput from './inputs/SelectSettingInput';
import SelectTimezoneSettingInput from './inputs/SelectTimezoneSettingInput';
import StringSettingInput from './inputs/StringSettingInput';
import TimespanSettingInput from './inputs/TimespanSettingInput';

// @todo: the props are loosely typed because `Setting` needs to typecheck them.
const inputsByType: Record<ISettingBase['type'], ElementType<any>> = {
Expand All @@ -39,6 +40,7 @@ const inputsByType: Record<ISettingBase['type'], ElementType<any>> = {
roomPick: RoomPickSettingInput,
timezone: SelectTimezoneSettingInput,
lookup: LookupSettingInput,
timespan: TimespanSettingInput,
date: GenericSettingInput, // @todo: implement
group: GenericSettingInput, // @todo: implement
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';

import { default as TimespanSettingInput, timeUnitToMs, msToTimeUnit, getHighestTimeUnit, TIMEUNIT } from './TimespanSettingInput';

global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));

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('TimespanSettingInput - timeUnitToMs - invalid time unit');
});
});

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('TimespanSettingInput - msToTimeUnit - invalid time unit');
});
});

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(
<TimespanSettingInput
disabled={false}
hasResetButton={false}
_id='timespanInput'
label='Timespan'
value='86400000' // 1 day
placeholder='Enter timespan'
onChangeValue={onChangeValueMock}
/>,
);

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 correctly convert value to minutes when changing time unit to minutes', () => {
render(
<TimespanSettingInput
disabled={false}
hasResetButton={false}
_id='timespanInput'
label='Timespan'
value='3600000' // 1 hour in milliseconds
placeholder='Enter timespan'
onChangeValue={onChangeValueMock}
/>,
);

const selectInput = screen.getByRole('button', { name: 'hours' });
userEvent.click(selectInput);
const minutesOption = screen.getByRole('option', { name: 'minutes' });
userEvent.click(minutesOption);

expect(screen.getByDisplayValue('60')).toBeTruthy();
});

it('should correctly convert value to hours when changing time unit to hours', () => {
render(
<TimespanSettingInput
disabled={false}
hasResetButton={false}
_id='timespanInput'
label='Timespan'
value='86400000' // 1 day in milliseconds
placeholder='Enter timespan'
onChangeValue={onChangeValueMock}
/>,
);

const selectInput = screen.getByRole('button', { name: 'days' });
userEvent.click(selectInput);
const hoursOption = screen.getByRole('option', { name: 'hours' });
userEvent.click(hoursOption);

expect(screen.getByDisplayValue('24')).toBeTruthy();
});

it('should correctly convert value to days when changing time unit to days', () => {
render(
<TimespanSettingInput
disabled={false}
hasResetButton={false}
_id='timespanInput'
label='Timespan'
value='43200000' // half a day
placeholder='Enter timespan'
onChangeValue={onChangeValueMock}
/>,
);

const selectInput = screen.getByRole('button', { name: 'hours' });
userEvent.click(selectInput);
const daysOption = screen.getByRole('option', { name: 'days' });
userEvent.click(daysOption);

expect(screen.getByDisplayValue('0.5')).toBeTruthy();
});

it('should call onResetButtonClick when reset button is clicked', () => {
render(
<TimespanSettingInput
disabled={false}
_id='timespanInput'
label='Timespan'
value='3600000' // 1 hour in milliseconds
placeholder='Enter timespan'
onChangeValue={onChangeValueMock}
hasResetButton
onResetButtonClick={onResetButtonClickMock}
/>,
);

const resetButton = screen.getByTitle('Reset');
userEvent.click(resetButton);

expect(onResetButtonClickMock).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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 ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';

type TimespanSettingInputProps = SettingInputProps<string, string | number> & {
value: string;
};

export enum TIMEUNIT {
days = 'days',
hours = 'hours',
minutes = 'minutes',
}

export const timeUnitToMs = (unit: TIMEUNIT, timespan: number) => {
if (unit === TIMEUNIT.days) {
gabriellsh marked this conversation as resolved.
Show resolved Hide resolved
return timespan * 24 * 60 * 60 * 1000;
}

if (unit === TIMEUNIT.hours) {
return timespan * 60 * 60 * 1000;
}

if (unit === TIMEUNIT.minutes) {
return timespan * 60 * 1000;
}

throw new Error('TimespanSettingInput - timeUnitToMs - invalid time unit');
};

export const msToTimeUnit = (unit: TIMEUNIT, timespan: number) => {
if (unit === TIMEUNIT.days) {
gabriellsh marked this conversation as resolved.
Show resolved Hide resolved
return timespan / 24 / 60 / 60 / 1000;
}

if (unit === TIMEUNIT.hours) {
return timespan / 60 / 60 / 1000;
}

if (unit === TIMEUNIT.minutes) {
return timespan / 60 / 1000;
}

throw new Error('TimespanSettingInput - msToTimeUnit - invalid time unit');
};

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;
};
gabriellsh marked this conversation as resolved.
Show resolved Hide resolved

function TimespanSettingInput({
_id,
label,
value,
placeholder,
readonly,
autocomplete,
disabled,
required,
onChangeValue,
hasResetButton,
onResetButtonClick,
}: TimespanSettingInputProps): ReactElement {
const t = useTranslation();

const [timeUnit, setTimeUnit] = useState<TIMEUNIT>(getHighestTimeUnit(Number(value)));
const [internalValue, setInternalValue] = useState<number>(msToTimeUnit(timeUnit, Number(value)));

const handleChange: FormEventHandler<HTMLInputElement> = (event) => {
const newValue = Math.max(1, Number(event.currentTarget.value)) || 1;

try {
setInternalValue(newValue);
Gustrb marked this conversation as resolved.
Show resolved Hide resolved
onChangeValue?.(timeUnitToMs(timeUnit, newValue));
} catch (error) {
console.log(error);
}
};

const handleChangeTimeUnit = (nextTimeUnit: string | number) => {
if (typeof nextTimeUnit !== 'string') {
return;
}
setTimeUnit((prevTimeUnit) => {
setInternalValue((currentValue) => msToTimeUnit(nextTimeUnit as TIMEUNIT, timeUnitToMs(prevTimeUnit, currentValue)));
return nextTimeUnit as TIMEUNIT;
});
};

const timeUnitOptions = useMemo(() => {
return Object.entries(TIMEUNIT).map<readonly [TIMEUNIT, string]>(([label, value]) => [value, t.has(label) ? t(label) : label]); // todo translate
}, [t]);

return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton data-qa-reset-setting-id={_id} onClick={onResetButtonClick} />}
</FieldRow>
<FieldRow>
<InputBox
data-qa-setting-id={_id}
id={_id}
type='number'
value={internalValue}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={handleChange}
/>
</FieldRow>
<FieldRow>
<Select value={timeUnit} disabled={disabled} options={timeUnitOptions} onChange={handleChangeTimeUnit} />
</FieldRow>
</Field>
);
}

export default TimespanSettingInput;
Loading
Loading