From bb88b92b4c71424520f4c6099b16eba86aaa6be2 Mon Sep 17 00:00:00 2001 From: Ben Cooke Date: Tue, 9 Jan 2024 14:10:22 -0500 Subject: [PATCH] [MM-53990] Support a global retention time of less than 1 day (#25196) * adding new MessageRetentionHours config --------- Co-authored-by: Mattermost Build --- .../support/server/default_config.ts | 2 + server/config/client.go | 8 +- server/i18n/en.json | 28 ++++- .../platform/services/telemetry/telemetry.go | 2 + server/public/model/config.go | 68 +++++++++++- server/public/model/config_test.go | 102 ++++++++++++++++++ .../data_retention_settings.test.tsx | 4 + .../data_retention_settings.tsx | 52 ++++++++- .../dropdown_options/dropdown_options.tsx | 2 + .../global_policy_form.test.tsx.snap | 20 +++- .../global_policy_form.scss | 6 ++ .../global_policy_form.test.tsx | 7 +- .../global_policy_form/global_policy_form.tsx | 88 ++++++++++----- .../global_policy_form/index.ts | 17 ++- .../data_retention_settings/index.ts | 5 + .../widgets/inputs/dropdown_input_hybrid.tsx | 4 + webapp/channels/src/i18n/en.json | 2 + webapp/platform/types/src/config.ts | 6 +- 18 files changed, 376 insertions(+), 47 deletions(-) diff --git a/e2e-tests/playwright/support/server/default_config.ts b/e2e-tests/playwright/support/server/default_config.ts index 0ee535ae33..78b43c61ef 100644 --- a/e2e-tests/playwright/support/server/default_config.ts +++ b/e2e-tests/playwright/support/server/default_config.ts @@ -611,7 +611,9 @@ const defaultServerConfig: AdminConfig = { EnableFileDeletion: false, EnableBoardsDeletion: false, MessageRetentionDays: 365, + MessageRetentionHours: 0, FileRetentionDays: 365, + FileRetentionHours: 0, BoardsRetentionDays: 365, DeletionJobStartTime: '02:00', BatchSize: 3000, diff --git a/server/config/client.go b/server/config/client.go index 213ade78ba..81250ca8cc 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -122,9 +122,9 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["AllowCustomThemes"] = "true" props["AllowedThemes"] = "" props["DataRetentionEnableMessageDeletion"] = "false" - props["DataRetentionMessageRetentionDays"] = "0" + props["DataRetentionMessageRetentionHours"] = "0" props["DataRetentionEnableFileDeletion"] = "false" - props["DataRetentionFileRetentionDays"] = "0" + props["DataRetentionFileRetentionHours"] = "0" props["CustomUrlSchemes"] = strings.Join(c.DisplaySettings.CustomURLSchemes, ",") props["MaxMarkdownNodes"] = strconv.FormatInt(int64(*c.DisplaySettings.MaxMarkdownNodes), 10) @@ -204,9 +204,9 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li if *license.Features.DataRetention { props["DataRetentionEnableMessageDeletion"] = strconv.FormatBool(*c.DataRetentionSettings.EnableMessageDeletion) - props["DataRetentionMessageRetentionDays"] = strconv.FormatInt(int64(*c.DataRetentionSettings.MessageRetentionDays), 10) + props["DataRetentionMessageRetentionHours"] = strconv.FormatInt(int64(c.DataRetentionSettings.GetMessageRetentionHours()), 10) props["DataRetentionEnableFileDeletion"] = strconv.FormatBool(*c.DataRetentionSettings.EnableFileDeletion) - props["DataRetentionFileRetentionDays"] = strconv.FormatInt(int64(*c.DataRetentionSettings.FileRetentionDays), 10) + props["DataRetentionFileRetentionHours"] = strconv.FormatInt(int64(c.DataRetentionSettings.GetFileRetentionHours()), 10) } if license.HasSharedChannels() { diff --git a/server/i18n/en.json b/server/i18n/en.json index 2813cbb21d..6c95f086fe 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -8742,13 +8742,37 @@ "id": "model.config.is_valid.data_retention.deletion_job_start_time.app_error", "translation": "Data retention job start time must be a 24-hour time stamp in the form HH:MM." }, + { + "id": "model.config.is_valid.data_retention.file_retention_both_zero.app_error", + "translation": "File retention days and file retention hours cannot both be 0." + }, { "id": "model.config.is_valid.data_retention.file_retention_days_too_low.app_error", - "translation": "File retention must be one day or longer." + "translation": "File retention days cannot be less than 0." + }, + { + "id": "model.config.is_valid.data_retention.file_retention_hours_too_low.app_error", + "translation": "File retention hours cannot be less than 0" + }, + { + "id": "model.config.is_valid.data_retention.file_retention_misconfiguration.app_error", + "translation": "File retention days and file retention hours cannot both be greater than 0." + }, + { + "id": "model.config.is_valid.data_retention.message_retention_both_zero.app_error", + "translation": "Message retention days and message retention hours cannot both be 0." }, { "id": "model.config.is_valid.data_retention.message_retention_days_too_low.app_error", - "translation": "Message retention must be one day or longer." + "translation": "Message retention days cannot be less than 0." + }, + { + "id": "model.config.is_valid.data_retention.message_retention_hours_too_low.app_error", + "translation": "Message retention hours cannot be less than 0" + }, + { + "id": "model.config.is_valid.data_retention.message_retention_misconfiguration.app_error", + "translation": "Message retention days and message retention hours cannot both be greater than 0." }, { "id": "model.config.is_valid.directory.app_error", diff --git a/server/platform/services/telemetry/telemetry.go b/server/platform/services/telemetry/telemetry.go index 7fe795861d..9ba4420bc2 100644 --- a/server/platform/services/telemetry/telemetry.go +++ b/server/platform/services/telemetry/telemetry.go @@ -829,7 +829,9 @@ func (ts *TelemetryService) trackConfig() { "enable_message_deletion": *cfg.DataRetentionSettings.EnableMessageDeletion, "enable_file_deletion": *cfg.DataRetentionSettings.EnableFileDeletion, "message_retention_days": *cfg.DataRetentionSettings.MessageRetentionDays, + "message_retention_hours": *cfg.DataRetentionSettings.MessageRetentionHours, "file_retention_days": *cfg.DataRetentionSettings.FileRetentionDays, + "file_retention_hours": *cfg.DataRetentionSettings.FileRetentionHours, "deletion_job_start_time": *cfg.DataRetentionSettings.DeletionJobStartTime, "batch_size": *cfg.DataRetentionSettings.BatchSize, "time_between_batches": *cfg.DataRetentionSettings.TimeBetweenBatchesMilliseconds, diff --git a/server/public/model/config.go b/server/public/model/config.go index ec88994770..c658f06eb3 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -207,7 +207,9 @@ const ( BleveSettingsDefaultBatchSize = 10000 DataRetentionSettingsDefaultMessageRetentionDays = 365 + DataRetentionSettingsDefaultMessageRetentionHours = 0 DataRetentionSettingsDefaultFileRetentionDays = 365 + DataRetentionSettingsDefaultFileRetentionHours = 0 DataRetentionSettingsDefaultBoardsRetentionDays = 365 DataRetentionSettingsDefaultDeletionJobStartTime = "02:00" DataRetentionSettingsDefaultBatchSize = 3000 @@ -2911,8 +2913,10 @@ type DataRetentionSettings struct { EnableMessageDeletion *bool `access:"compliance_data_retention_policy"` EnableFileDeletion *bool `access:"compliance_data_retention_policy"` EnableBoardsDeletion *bool `access:"compliance_data_retention_policy"` - MessageRetentionDays *int `access:"compliance_data_retention_policy"` - FileRetentionDays *int `access:"compliance_data_retention_policy"` + MessageRetentionDays *int `access:"compliance_data_retention_policy"` // Deprecated: use `MessageRetentionHours` + MessageRetentionHours *int `access:"compliance_data_retention_policy"` + FileRetentionDays *int `access:"compliance_data_retention_policy"` // Deprecated: use `FileRetentionHours` + FileRetentionHours *int `access:"compliance_data_retention_policy"` BoardsRetentionDays *int `access:"compliance_data_retention_policy"` DeletionJobStartTime *string `access:"compliance_data_retention_policy"` BatchSize *int `access:"compliance_data_retention_policy"` @@ -2937,10 +2941,18 @@ func (s *DataRetentionSettings) SetDefaults() { s.MessageRetentionDays = NewInt(DataRetentionSettingsDefaultMessageRetentionDays) } + if s.MessageRetentionHours == nil { + s.MessageRetentionHours = NewInt(DataRetentionSettingsDefaultMessageRetentionHours) + } + if s.FileRetentionDays == nil { s.FileRetentionDays = NewInt(DataRetentionSettingsDefaultFileRetentionDays) } + if s.FileRetentionHours == nil { + s.FileRetentionHours = NewInt(DataRetentionSettingsDefaultFileRetentionHours) + } + if s.BoardsRetentionDays == nil { s.BoardsRetentionDays = NewInt(DataRetentionSettingsDefaultBoardsRetentionDays) } @@ -2961,6 +2973,30 @@ func (s *DataRetentionSettings) SetDefaults() { } } +// GetMessageRetentionHours returns the message retention time as an int. +// MessageRetentionHours takes precedence over the deprecated MessageRetentionDays. +func (s *DataRetentionSettings) GetMessageRetentionHours() int { + if s.MessageRetentionHours != nil && *s.MessageRetentionHours > 0 { + return *s.MessageRetentionHours + } + if s.MessageRetentionDays != nil && *s.MessageRetentionDays > 0 { + return *s.MessageRetentionDays * 24 + } + return DataRetentionSettingsDefaultMessageRetentionDays * 24 +} + +// GetFileRetentionHours returns the message retention time as an int. +// FileRetentionHours takes precedence over the deprecated FileRetentionDays. +func (s *DataRetentionSettings) GetFileRetentionHours() int { + if s.FileRetentionHours != nil && *s.FileRetentionHours > 0 { + return *s.FileRetentionHours + } + if s.FileRetentionDays != nil && *s.FileRetentionDays > 0 { + return *s.FileRetentionDays * 24 + } + return DataRetentionSettingsDefaultFileRetentionDays * 24 +} + type JobSettings struct { RunJobs *bool `access:"write_restrictable,cloud_restrictable"` // telemetry: none RunScheduler *bool `access:"write_restrictable,cloud_restrictable"` // telemetry: none @@ -4141,14 +4177,38 @@ func (bs *BleveSettings) isValid() *AppError { } func (s *DataRetentionSettings) isValid() *AppError { - if *s.MessageRetentionDays <= 0 { + if s.MessageRetentionDays == nil || *s.MessageRetentionDays < 0 { return NewAppError("Config.IsValid", "model.config.is_valid.data_retention.message_retention_days_too_low.app_error", nil, "", http.StatusBadRequest) } - if *s.FileRetentionDays <= 0 { + if s.MessageRetentionHours == nil || *s.MessageRetentionHours < 0 { + return NewAppError("Config.IsValid", "model.config.is_valid.data_retention.message_retention_hours_too_low.app_error", nil, "", http.StatusBadRequest) + } + + if s.FileRetentionDays == nil || *s.FileRetentionDays < 0 { return NewAppError("Config.IsValid", "model.config.is_valid.data_retention.file_retention_days_too_low.app_error", nil, "", http.StatusBadRequest) } + if s.FileRetentionHours == nil || *s.FileRetentionHours < 0 { + return NewAppError("Config.IsValid", "model.config.is_valid.data_retention.file_retention_hours_too_low.app_error", nil, "", http.StatusBadRequest) + } + + if *s.MessageRetentionDays > 0 && *s.MessageRetentionHours > 0 { + return NewAppError("Config.IsValid", "model.config.is_valid.data_retention.message_retention_misconfiguration.app_error", nil, "", http.StatusBadRequest) + } + + if *s.FileRetentionDays > 0 && *s.FileRetentionHours > 0 { + return NewAppError("Config.IsValid", "model.config.is_valid.data_retention.file_retention_misconfiguration.app_error", nil, "", http.StatusBadRequest) + } + + if *s.MessageRetentionDays == 0 && *s.MessageRetentionHours == 0 { + return NewAppError("Config.IsValid", "model.config.is_valid.data_retention.message_retention_both_zero.app_error", nil, "", http.StatusBadRequest) + } + + if *s.FileRetentionDays == 0 && *s.FileRetentionHours == 0 { + return NewAppError("Config.IsValid", "model.config.is_valid.data_retention.file_retention_both_zero.app_error", nil, "", http.StatusBadRequest) + } + if _, err := time.Parse("15:04", *s.DeletionJobStartTime); err != nil { return NewAppError("Config.IsValid", "model.config.is_valid.data_retention.deletion_job_start_time.app_error", nil, "", http.StatusBadRequest).Wrap(err) } diff --git a/server/public/model/config_test.go b/server/public/model/config_test.go index 78a3ee0832..a2804cae28 100644 --- a/server/public/model/config_test.go +++ b/server/public/model/config_test.go @@ -1647,3 +1647,105 @@ func TestConfigDefaultCallsPluginState(t *testing.T) { assert.False(t, c1.PluginSettings.PluginStates["com.mattermost.calls"].Enable) }) } + +func TestConfigGetMessageRetentionHours(t *testing.T) { + tests := []struct { + name string + config Config + value int + }{ + { + name: "should return MessageRetentionDays config value in hours by default", + config: Config{}, + value: 8760, + }, + { + name: "should return MessageRetentionHours config value", + config: Config{ + DataRetentionSettings: DataRetentionSettings{ + MessageRetentionHours: NewInt(48), + }, + }, + value: 48, + }, + { + name: "should return MessageRetentionHours config value", + config: Config{ + DataRetentionSettings: DataRetentionSettings{ + MessageRetentionDays: NewInt(50), + MessageRetentionHours: NewInt(48), + }, + }, + value: 48, + }, + { + name: "should return MessageRetentionDays config value in hours", + config: Config{ + DataRetentionSettings: DataRetentionSettings{ + MessageRetentionDays: NewInt(50), + MessageRetentionHours: NewInt(0), + }, + }, + value: 1200, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + test.config.SetDefaults() + + require.Equal(t, test.value, test.config.DataRetentionSettings.GetMessageRetentionHours()) + }) + } +} + +func TestConfigGetFileRetentionHours(t *testing.T) { + tests := []struct { + name string + config Config + value int + }{ + { + name: "should return FileRetentionDays config value in hours by default", + config: Config{}, + value: 8760, + }, + { + name: "should return FileRetentionHours config value", + config: Config{ + DataRetentionSettings: DataRetentionSettings{ + FileRetentionHours: NewInt(48), + }, + }, + value: 48, + }, + { + name: "should return FileRetentionHours config value", + config: Config{ + DataRetentionSettings: DataRetentionSettings{ + FileRetentionDays: NewInt(50), + FileRetentionHours: NewInt(48), + }, + }, + value: 48, + }, + { + name: "should return FileRetentionDays config value in hours", + config: Config{ + DataRetentionSettings: DataRetentionSettings{ + FileRetentionDays: NewInt(50), + FileRetentionHours: NewInt(0), + }, + }, + value: 1200, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + test.config.SetDefaults() + + require.Equal(t, test.value, test.config.DataRetentionSettings.GetFileRetentionHours()) + }) + } +} diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/data_retention_settings.test.tsx b/webapp/channels/src/components/admin_console/data_retention_settings/data_retention_settings.test.tsx index f528c28389..0fd877624a 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/data_retention_settings.test.tsx +++ b/webapp/channels/src/components/admin_console/data_retention_settings/data_retention_settings.test.tsx @@ -13,12 +13,16 @@ describe('components/admin_console/data_retention_settings/data_retention_settin EnableMessageDeletion: true, EnableFileDeletion: true, MessageRetentionDays: 100, + MessageRetentionHours: 2400, FileRetentionDays: 100, + FileRetentionHours: 2400, DeletionJobStartTime: '00:15', }, }, customPolicies: {}, customPoliciesCount: 0, + globalMessageRetentionHours: '2400', + globalFileRetentionHours: '2400', actions: { getDataRetentionCustomPolicies: jest.fn().mockResolvedValue([]), createJob: jest.fn(), diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/data_retention_settings.tsx b/webapp/channels/src/components/admin_console/data_retention_settings/data_retention_settings.tsx index 760caca2c7..b692609941 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/data_retention_settings.tsx +++ b/webapp/channels/src/components/admin_console/data_retention_settings/data_retention_settings.tsx @@ -37,6 +37,8 @@ type Props = { config: DeepPartial; customPolicies: DataRetentionCustomPolicies; customPoliciesCount: number; + globalMessageRetentionHours: string | undefined; + globalFileRetentionHours: string | undefined; actions: { getDataRetentionCustomPolicies: (page: number) => Promise<{ data: DataRetentionCustomPolicies }>; createJob: (job: JobTypeBase) => Promise<{ data: any }>; @@ -147,6 +149,52 @@ export default class DataRetentionSettings extends React.PureComponent { + if (!enabled) { + return ( + + ); + } + const hoursInt = parseInt(hours || '', 10); + if (hoursInt && hoursInt % 8760 === 0) { + const years = hoursInt / 8760; + return ( + + ); + } + if (hoursInt && hoursInt % 24 === 0) { + const days = hoursInt / 24; + return ( + + ); + } + + return ( + + ); + }; getMessageRetentionSetting = (enabled: boolean | undefined, days: number | undefined): JSX.Element => { if (!enabled) { return ( @@ -185,12 +233,12 @@ export default class DataRetentionSettings extends React.PureComponent - {this.getMessageRetentionSetting(DataRetentionSettings?.EnableMessageDeletion, DataRetentionSettings?.MessageRetentionDays)} + {this.getGlobalRetentionSetting(DataRetentionSettings?.EnableMessageDeletion, this.props.globalMessageRetentionHours)} ), files: (
- {this.getMessageRetentionSetting(DataRetentionSettings?.EnableFileDeletion, DataRetentionSettings?.FileRetentionDays)} + {this.getGlobalRetentionSetting(DataRetentionSettings?.EnableFileDeletion, this.props.globalFileRetentionHours)}
), actions: ( diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/dropdown_options/dropdown_options.tsx b/webapp/channels/src/components/admin_console/data_retention_settings/dropdown_options/dropdown_options.tsx index 183e96192f..c0178dd03b 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/dropdown_options/dropdown_options.tsx +++ b/webapp/channels/src/components/admin_console/data_retention_settings/dropdown_options/dropdown_options.tsx @@ -8,6 +8,8 @@ import * as Utils from 'utils/utils'; export const FOREVER = 'FOREVER'; export const YEARS = 'YEARS'; export const DAYS = 'DAYS'; +export const HOURS = 'HOURS'; export const keepForeverOption = () => ({value: FOREVER, label:
{Utils.localizeMessage('admin.data_retention.form.keepForever', 'Keep forever')}
}); export const yearsOption = () => ({value: YEARS, label: {Utils.localizeMessage('admin.data_retention.form.years', 'Years')}}); export const daysOption = () => ({value: DAYS, label: {Utils.localizeMessage('admin.data_retention.form.days', 'Days')}}); +export const hoursOption = () => ({value: HOURS, label: {Utils.localizeMessage('admin.data_retention.form.hours', 'Hours')}}); diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/__snapshots__/global_policy_form.test.tsx.snap b/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/__snapshots__/global_policy_form.test.tsx.snap index 33457912eb..0a4eca2107 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/__snapshots__/global_policy_form.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/__snapshots__/global_policy_form.test.tsx.snap @@ -62,13 +62,21 @@ exports[`components/PluginManagement should match snapshot 1`] = ` } inputId="channel_message_retention_input" inputType="number" - inputValue="60" + inputValue="100" legend="Channel & direct message retention" name="channel_message_retention" onDropdownChange={[Function]} onInputChange={[Function]} options={ Array [ + Object { + "label": + Hours + , + "value": "HOURS", + }, Object { "label": + Hours + , + "value": "HOURS", + }, Object { "label": { DataRetentionSettings: { EnableMessageDeletion: true, EnableFileDeletion: true, - MessageRetentionDays: 60, - FileRetentionDays: 40, + MessageRetentionHours: 1440, + FileRetentionHours: 960, DeletionJobStartTime: '10:00', }, }, + messageRetentionHours: '2400', + fileRetentionHours: '2400', + environmentConfig: {}, actions: { updateConfig: jest.fn(), setNavigationBlocked: jest.fn(), diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/global_policy_form.tsx b/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/global_policy_form.tsx index acd005f84b..708402d272 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/global_policy_form.tsx +++ b/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/global_policy_form.tsx @@ -4,12 +4,13 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; -import type {AdminConfig} from '@mattermost/types/config'; +import type {AdminConfig, EnvironmentConfig} from '@mattermost/types/config'; import type {ServerError} from '@mattermost/types/errors'; import type {DeepPartial} from '@mattermost/types/utilities'; import BlockableLink from 'components/admin_console/blockable_link'; -import {keepForeverOption, yearsOption, daysOption, FOREVER, YEARS, DAYS} from 'components/admin_console/data_retention_settings/dropdown_options/dropdown_options'; +import {keepForeverOption, yearsOption, daysOption, FOREVER, YEARS, DAYS, hoursOption} from 'components/admin_console/data_retention_settings/dropdown_options/dropdown_options'; +import SetByEnv from 'components/admin_console/set_by_env'; import Card from 'components/card/card'; import SaveButton from 'components/save_button'; import AdminHeader from 'components/widgets/admin_console/admin_header'; @@ -26,6 +27,9 @@ type ValueType = { } type Props = { config: DeepPartial; + messageRetentionHours: string | undefined; + fileRetentionHours: string | undefined; + environmentConfig: Partial; actions: { updateConfig: (config: Record) => Promise<{ data?: AdminConfig; error?: ServerError }>; setNavigationBlocked: (blocked: boolean) => void; @@ -51,30 +55,44 @@ export default class GlobalPolicyForm extends React.PureComponent saving: false, serverError: null, formErrorText: '', - messageRetentionDropdownValue: this.getDefaultDropdownValue(DataRetentionSettings?.EnableMessageDeletion, DataRetentionSettings?.MessageRetentionDays), - messageRetentionInputValue: this.getDefaultInputValue(DataRetentionSettings?.EnableMessageDeletion, DataRetentionSettings?.MessageRetentionDays), - fileRetentionDropdownValue: this.getDefaultDropdownValue(DataRetentionSettings?.EnableFileDeletion, DataRetentionSettings?.FileRetentionDays), - fileRetentionInputValue: this.getDefaultInputValue(DataRetentionSettings?.EnableFileDeletion, DataRetentionSettings?.FileRetentionDays), + messageRetentionDropdownValue: this.getDefaultDropdownValue(DataRetentionSettings?.EnableMessageDeletion, props.messageRetentionHours), + messageRetentionInputValue: this.getDefaultInputValue(DataRetentionSettings?.EnableMessageDeletion, props.messageRetentionHours), + fileRetentionDropdownValue: this.getDefaultDropdownValue(DataRetentionSettings?.EnableFileDeletion, props.fileRetentionHours), + fileRetentionInputValue: this.getDefaultInputValue(DataRetentionSettings?.EnableFileDeletion, props.fileRetentionHours), }; } - getDefaultInputValue = (isEnabled: boolean | undefined, days: number | undefined): string => { - if (!isEnabled || days === undefined) { + getDefaultInputValue = (isEnabled: boolean | undefined, hours: string | undefined): string => { + if (!isEnabled || hours === undefined) { return ''; } - if (days % 365 === 0) { - return (days / 365).toString(); + const hoursInt = parseInt(hours, 10); + + // 8760 hours in a year + if (hoursInt % 8760 === 0) { + return (hoursInt / 8760).toString(); } - return days.toString(); + if (hoursInt % 24 === 0) { + return (hoursInt / 24).toString(); + } + + return hours.toString(); }; - getDefaultDropdownValue = (isEnabled: boolean | undefined, days: number | undefined) => { - if (!isEnabled || days === undefined) { + getDefaultDropdownValue = (isEnabled: boolean | undefined, hours: string | undefined) => { + if (!isEnabled || hours === undefined) { return keepForeverOption(); } - if (days % 365 === 0) { + const hoursInt = parseInt(hours, 10); + + // 8760 hours in a year + if (hoursInt % 8760 === 0) { return yearsOption(); } - return daysOption(); + if (hoursInt % 24 === 0) { + return daysOption(); + } + + return hoursOption(); }; handleSubmit = async () => { @@ -90,16 +108,16 @@ export default class GlobalPolicyForm extends React.PureComponent newConfig.DataRetentionSettings.EnableMessageDeletion = this.setDeletionEnabled(messageRetentionDropdownValue.value); - const messageDays = this.setRetentionDays(messageRetentionDropdownValue.value, messageRetentionInputValue); - if (messageDays >= 1) { - newConfig.DataRetentionSettings.MessageRetentionDays = messageDays; + if (!this.isMessageRetentionSetByEnv() && this.setDeletionEnabled(messageRetentionDropdownValue.value)) { + newConfig.DataRetentionSettings.MessageRetentionDays = 0; + newConfig.DataRetentionSettings.MessageRetentionHours = this.setRetentionHours(messageRetentionDropdownValue.value, messageRetentionInputValue); } newConfig.DataRetentionSettings.EnableFileDeletion = this.setDeletionEnabled(fileRetentionDropdownValue.value); - const fileDays = this.setRetentionDays(fileRetentionDropdownValue.value, fileRetentionInputValue); - if (fileDays >= 1) { - newConfig.DataRetentionSettings.FileRetentionDays = fileDays; + if (!this.isFileRetentionSetByEnv() && this.setDeletionEnabled(fileRetentionDropdownValue.value)) { + newConfig.DataRetentionSettings.FileRetentionDays = 0; + newConfig.DataRetentionSettings.FileRetentionHours = this.setRetentionHours(fileRetentionDropdownValue.value, fileRetentionInputValue); } const {error} = await this.props.actions.updateConfig(newConfig); @@ -119,16 +137,26 @@ export default class GlobalPolicyForm extends React.PureComponent return true; }; - setRetentionDays = (dropdownValue: string, value: string): number => { + setRetentionHours = (dropdownValue: string, value: string): number => { if (dropdownValue === YEARS) { - return parseInt(value, 10) * 365; + return parseInt(value, 10) * 24 * 365; } - if (dropdownValue === DAYS) { - return parseInt(value, 10); + return parseInt(value, 10) * 24; } + return parseInt(value, 10); + }; - return 0; + isMessageRetentionSetByEnv = () => { + return (this.props.environmentConfig?.DataRetentionSettings?.MessageRetentionDays && this.props.config.DataRetentionSettings?.MessageRetentionDays && this.props.config.DataRetentionSettings.MessageRetentionDays > 0) || + (this.props.environmentConfig?.DataRetentionSettings?.MessageRetentionHours && this.props.config.DataRetentionSettings?.MessageRetentionHours && this.props.config.DataRetentionSettings.MessageRetentionHours > 0) || + (this.props.environmentConfig?.DataRetentionSettings?.EnableMessageDeletion && !this.props.config.DataRetentionSettings?.EnableMessageDeletion); + }; + + isFileRetentionSetByEnv = () => { + return (this.props.environmentConfig?.DataRetentionSettings?.FileRetentionDays && this.props.config.DataRetentionSettings?.FileRetentionDays && this.props.config.DataRetentionSettings.FileRetentionDays > 0) || + (this.props.environmentConfig?.DataRetentionSettings?.FileRetentionHours && this.props.config.DataRetentionSettings?.FileRetentionHours && this.props.config.DataRetentionSettings.FileRetentionHours > 0) || + (this.props.environmentConfig?.DataRetentionSettings?.EnableFileDeletion && !this.props.config.DataRetentionSettings?.EnableFileDeletion); }; render = () => { @@ -173,8 +201,9 @@ export default class GlobalPolicyForm extends React.PureComponent inputValue={this.state.messageRetentionInputValue} width={90} exceptionToInput={[FOREVER]} + disabled={this.isMessageRetentionSetByEnv()} defaultValue={keepForeverOption()} - options={[daysOption(), yearsOption(), keepForeverOption()]} + options={[hoursOption(), daysOption(), yearsOption(), keepForeverOption()]} legend={Utils.localizeMessage('admin.data_retention.form.channelAndDirectMessageRetention', 'Channel & direct message retention')} placeholder={Utils.localizeMessage('admin.data_retention.form.channelAndDirectMessageRetention', 'Channel & direct message retention')} name={'channel_message_retention'} @@ -182,6 +211,7 @@ export default class GlobalPolicyForm extends React.PureComponent dropdownClassNamePrefix={'channel_message_retention_dropdown'} inputId={'channel_message_retention_input'} /> + {this.isMessageRetentionSetByEnv() && }
inputValue={this.state.fileRetentionInputValue} width={90} exceptionToInput={[FOREVER]} + disabled={this.isFileRetentionSetByEnv()} defaultValue={keepForeverOption()} - options={[daysOption(), yearsOption(), keepForeverOption()]} + options={[hoursOption(), daysOption(), yearsOption(), keepForeverOption()]} legend={Utils.localizeMessage('admin.data_retention.form.fileRetention', 'File retention')} placeholder={Utils.localizeMessage('admin.data_retention.form.fileRetention', 'File retention')} name={'file_retention'} @@ -208,6 +239,7 @@ export default class GlobalPolicyForm extends React.PureComponent dropdownClassNamePrefix={'file_retention_dropdown'} inputId={'file_retention_input'} /> + {this.isFileRetentionSetByEnv() && }
diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/index.ts b/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/index.ts index d24dc54c34..e8fa89326d 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/index.ts +++ b/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/index.ts @@ -11,10 +11,14 @@ import type {ServerError} from '@mattermost/types/errors'; import { updateConfig, } from 'mattermost-redux/actions/admin'; +import {getEnvironmentConfig} from 'mattermost-redux/selectors/entities/admin'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; import type {GenericAction, ActionFunc} from 'mattermost-redux/types/actions'; import {setNavigationBlocked} from 'actions/admin_actions.jsx'; +import type {GlobalState} from 'types/store'; + import GlobalPolicyForm from './global_policy_form'; type Actions = { @@ -22,6 +26,17 @@ type Actions = { setNavigationBlocked: (blocked: boolean) => void; }; +function mapStateToProps(state: GlobalState) { + const messageRetentionHours = getConfig(state).DataRetentionMessageRetentionHours; + const fileRetentionHours = getConfig(state).DataRetentionFileRetentionHours; + + return { + messageRetentionHours, + fileRetentionHours, + environmentConfig: getEnvironmentConfig(state), + }; +} + function mapDispatchToProps(dispatch: Dispatch) { return { actions: bindActionCreators, Actions>({ @@ -31,4 +46,4 @@ function mapDispatchToProps(dispatch: Dispatch) { }; } -export default connect(null, mapDispatchToProps)(GlobalPolicyForm); +export default connect(mapStateToProps, mapDispatchToProps)(GlobalPolicyForm); diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/index.ts b/webapp/channels/src/components/admin_console/data_retention_settings/index.ts index bb1db0e66f..ca5fe9bf3a 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/index.ts +++ b/webapp/channels/src/components/admin_console/data_retention_settings/index.ts @@ -11,6 +11,7 @@ import type {JobTypeBase, JobType} from '@mattermost/types/jobs'; import {getDataRetentionCustomPolicies as fetchDataRetentionCustomPolicies, deleteDataRetentionCustomPolicy, updateConfig} from 'mattermost-redux/actions/admin'; import {createJob, getJobsByType} from 'mattermost-redux/actions/jobs'; import {getDataRetentionCustomPolicies, getDataRetentionCustomPoliciesCount} from 'mattermost-redux/selectors/entities/admin'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; import type {GenericAction, ActionFunc, ActionResult} from 'mattermost-redux/types/actions'; import type {GlobalState} from 'types/store'; @@ -28,10 +29,14 @@ type Actions = { function mapStateToProps(state: GlobalState) { const customPolicies = getDataRetentionCustomPolicies(state); const customPoliciesCount = getDataRetentionCustomPoliciesCount(state); + const globalMessageRetentionHours = getConfig(state).DataRetentionMessageRetentionHours; + const globalFileRetentionHours = getConfig(state).DataRetentionFileRetentionHours; return { customPolicies, customPoliciesCount, + globalMessageRetentionHours, + globalFileRetentionHours, }; } diff --git a/webapp/channels/src/components/widgets/inputs/dropdown_input_hybrid.tsx b/webapp/channels/src/components/widgets/inputs/dropdown_input_hybrid.tsx index a0e4513a01..dd44c8a37d 100644 --- a/webapp/channels/src/components/widgets/inputs/dropdown_input_hybrid.tsx +++ b/webapp/channels/src/components/widgets/inputs/dropdown_input_hybrid.tsx @@ -44,6 +44,8 @@ const baseStyles = { boxShadow: 'none', padding: '0 2px', cursor: 'pointer', + minHeight: '40px', + borderRadius: '0', }), indicatorSeparator: (provided: CSSProperties) => ({ ...provided, @@ -206,6 +208,7 @@ const DropdownInputHybrid = (props: Props) className={classNames('Input form-control')} ref={inputRef} id={inputId} + disabled={props.disabled} />
(props: Props) hideSelectedOptions={true} isSearchable={false} menuPortalTarget={document.body} + isDisabled={props.disabled} {...otherProps} />
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index a88467a324..884a7b2e3d 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -722,6 +722,7 @@ "admin.data_retention.form.channelAndDirectMessageRetention": "Channel & direct message retention", "admin.data_retention.form.days": "Days", "admin.data_retention.form.fileRetention": "File retention", + "admin.data_retention.form.hours": "Hours", "admin.data_retention.form.keepForever": "Keep forever", "admin.data_retention.form.text": "Applies to all teams and channels, but does not apply to custom retention policies.", "admin.data_retention.form.years": "Years", @@ -739,6 +740,7 @@ "admin.data_retention.jobTimeAM": "{time} AM (UTC)", "admin.data_retention.jobTimePM": "{time} PM (UTC)", "admin.data_retention.retention_days": "{count} {count, plural, one {day} other {days}}", + "admin.data_retention.retention_hours": "{count} {count, plural, one {hour} other {hours}}", "admin.data_retention.retention_years": "{count} {count, plural, one {year} other {years}}", "admin.data_retention.settings.title": "Data Retention Policies", "admin.data_retention.title": "Data Retention Policy", diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index 8112d2233d..fd30a2e886 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -33,8 +33,8 @@ export type ClientConfig = { CWSMock: string; DataRetentionEnableFileDeletion: string; DataRetentionEnableMessageDeletion: string; - DataRetentionFileRetentionDays: string; - DataRetentionMessageRetentionDays: string; + DataRetentionFileRetentionHours: string; + DataRetentionMessageRetentionHours: string; DefaultClientLocale: string; DefaultTheme: string; DiagnosticId: string; @@ -828,7 +828,9 @@ export type DataRetentionSettings = { EnableMessageDeletion: boolean; EnableFileDeletion: boolean; MessageRetentionDays: number; + MessageRetentionHours: number; FileRetentionDays: number; + FileRetentionHours: number; DeletionJobStartTime: string; BatchSize: number; EnableBoardsDeletion: boolean,