diff --git a/assets/js/common/ActivityLogsSettingsModal/ActivityLogsSettingsModal.jsx b/assets/js/common/ActivityLogsSettingsModal/ActivityLogsSettingsModal.jsx
index 8889a87f14..ee6a157b38 100644
--- a/assets/js/common/ActivityLogsSettingsModal/ActivityLogsSettingsModal.jsx
+++ b/assets/js/common/ActivityLogsSettingsModal/ActivityLogsSettingsModal.jsx
@@ -7,13 +7,25 @@ import { InputNumber } from '@common/Input';
import Select from '@common/Select';
import Label from '@common/Label';
-import { hasError, getError } from '@lib/api/validationErrors';
+import { getError } from '@lib/api/validationErrors';
const defaultErrors = [];
const timeUnitOptions = ['day', 'week', 'month', 'year'];
const defaultTimeUnit = timeUnitOptions[0];
+const toRetentionTimeErrorMessage = (errors) =>
+ [
+ capitalize(getError('retention_time/value', errors)),
+ capitalize(getError('retention_time/unit', errors)),
+ ]
+ .filter(Boolean)
+ .join('; ');
+
+const toGenericErrorMessage = (errors) =>
+ // the first error of type string is considered the generic error
+ errors.find((error) => typeof error === 'string');
+
function TimeSpan({ time: initialTime, error = false, onChange = noop }) {
const [time, setTime] = useState(initialTime);
@@ -24,7 +36,7 @@ function TimeSpan({ time: initialTime, error = false, onChange = noop }) {
value={time.value}
className="!h-8"
type="number"
- min="0"
+ min="1"
error={error}
onChange={(value) => {
const newTime = { ...time, value };
@@ -50,6 +62,19 @@ function TimeSpan({ time: initialTime, error = false, onChange = noop }) {
);
}
+/**
+ * Display an error message. If no error is provided, an empty space is displayed to keep the layout stable
+ * @param {string} text The error message to display
+ * @returns {JSX.Element}
+ */
+function Error({ text }) {
+ return text ? (
+
{text}
+ ) : (
+
+ );
+}
+
/**
* Modal to edit Activity Logs settings
*/
@@ -64,6 +89,9 @@ function ActivityLogsSettingsModal({
}) {
const [retentionTime, setRetentionTime] = useState(initialRetentionTime);
+ const retentionTimeError = toRetentionTimeErrorMessage(errors);
+ const genericError = toGenericErrorMessage(errors);
+
return (
@@ -73,20 +101,13 @@ function ActivityLogsSettingsModal({
{
setRetentionTime(time);
onClearErrors();
}}
/>
- {hasError('retentionTime', errors) && (
-
- {capitalize(getError('retentionTime', errors))}
-
- )}
+
@@ -98,7 +119,7 @@ function ActivityLogsSettingsModal({
disabled={loading}
onClick={() => {
const payload = {
- retentionTime,
+ retention_time: retentionTime,
};
onSave(payload);
}}
@@ -109,6 +130,9 @@ function ActivityLogsSettingsModal({
Cancel
+
+
+
);
}
diff --git a/assets/js/common/ActivityLogsSettingsModal/ActivityLogsSettingsModal.stories.jsx b/assets/js/common/ActivityLogsSettingsModal/ActivityLogsSettingsModal.stories.jsx
index 90760212d8..547ada2041 100644
--- a/assets/js/common/ActivityLogsSettingsModal/ActivityLogsSettingsModal.stories.jsx
+++ b/assets/js/common/ActivityLogsSettingsModal/ActivityLogsSettingsModal.stories.jsx
@@ -31,16 +31,43 @@ export const Default = {
},
};
-export const WithErrors = {
+export const WithFieldValidationError = {
args: {
open: false,
initialRetentionTime: { value: 1, unit: 'month' },
errors: [
{
- detail: "can't be blank",
- source: { pointer: '/retentionTime' },
+ detail: 'must be greater than or equal to 1',
+ source: { pointer: '/retention_time/value' },
title: 'Invalid value',
},
],
},
};
+
+export const WithCompositeFieldValidationError = {
+ args: {
+ open: false,
+ initialRetentionTime: { value: 1, unit: 'month' },
+ errors: [
+ {
+ detail: 'must be greater than or equal to 1',
+ source: { pointer: '/retention_time/value' },
+ title: 'Invalid value',
+ },
+ {
+ detail: 'invalid time unit',
+ source: { pointer: '/retention_time/unit' },
+ title: 'Invalid unit',
+ },
+ ],
+ },
+};
+
+export const WithGenericError = {
+ args: {
+ open: false,
+ initialRetentionTime: { value: 1, unit: 'month' },
+ errors: ['any error'],
+ },
+};
diff --git a/assets/js/common/ActivityLogsSettingsModal/ActivityLogsSettingsModal.test.jsx b/assets/js/common/ActivityLogsSettingsModal/ActivityLogsSettingsModal.test.jsx
index 7bd9771e40..3aa6553430 100644
--- a/assets/js/common/ActivityLogsSettingsModal/ActivityLogsSettingsModal.test.jsx
+++ b/assets/js/common/ActivityLogsSettingsModal/ActivityLogsSettingsModal.test.jsx
@@ -4,8 +4,6 @@ import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
-import { capitalize } from 'lodash';
-
import ActivityLogsSettingsModal from '.';
const positiveInt = () => faker.number.int({ min: 1 });
@@ -83,7 +81,7 @@ describe('ActivityLogsSettingsModal component', () => {
await user.click(screen.getByText('Save Settings'));
expect(onSave).toHaveBeenCalledWith({
- retentionTime: expectedRetentionTime,
+ retention_time: expectedRetentionTime,
});
});
@@ -106,34 +104,50 @@ describe('ActivityLogsSettingsModal component', () => {
await user.click(screen.getByText('Save Settings'));
expect(onSave).toHaveBeenCalledWith({
- retentionTime: initialRetentionTime,
+ retention_time: initialRetentionTime,
});
});
- it('should display errors', async () => {
- const detail = capitalize(faker.lorem.words(5));
- const initialRetentionTime = { value: positiveInt(), unit: 'month' };
-
- const errors = [
- {
- detail,
- source: { pointer: '/retentionTime' },
- title: 'Invalid value',
- },
- ];
-
- await act(async () => {
- render(
- {}}
- onCancel={() => {}}
- />
- );
- });
+ const valueError = {
+ detail: faker.lorem.words(20),
+ source: { pointer: '/retention_time/value' },
+ title: faker.lorem.words(10),
+ };
+ const unitError = {
+ detail: faker.lorem.words(20),
+ source: { pointer: '/retention_time/unit' },
+ title: faker.lorem.words(10),
+ };
+
+ it.each`
+ scenario | errors | expectedErrorMessage
+ ${'value error'} | ${[valueError]} | ${valueError.detail}
+ ${'unit error'} | ${[unitError]} | ${unitError.detail}
+ ${'value and unit errors (1)'} | ${[valueError, unitError]} | ${valueError.detail}
+ ${'value and unit errors (2)'} | ${[valueError, unitError]} | ${unitError.detail}
+ ${'generic error'} | ${['a generic error']} | ${'a generic error'}
+ ${'generic error and value error (1)'} | ${['a generic error', valueError]} | ${'a generic error'}
+ ${'generic error and value error (2)'} | ${['a generic error', valueError]} | ${valueError.detail}
+ `(
+ 'should display errors on $scenario',
+ async ({ errors, expectedErrorMessage }) => {
+ const initialRetentionTime = { value: positiveInt(), unit: 'month' };
+
+ await act(async () => {
+ render(
+ {}}
+ onCancel={() => {}}
+ />
+ );
+ });
- expect(screen.getAllByText(detail)).toHaveLength(1);
- });
+ expect(
+ screen.getAllByText(expectedErrorMessage, { exact: false })
+ ).toHaveLength(1);
+ }
+ );
});
diff --git a/assets/js/lib/api/activityLogsSettings.js b/assets/js/lib/api/activityLogsSettings.js
new file mode 100644
index 0000000000..e4633b1b05
--- /dev/null
+++ b/assets/js/lib/api/activityLogsSettings.js
@@ -0,0 +1,6 @@
+import { networkClient } from '@lib/network';
+
+export const getSettings = () => networkClient.get(`/settings/activity_log`);
+
+export const updateSettings = (settings) =>
+ networkClient.put(`/settings/activity_log`, settings);
diff --git a/assets/js/lib/api/validationErrors.test.js b/assets/js/lib/api/validationErrors.test.js
index b764214999..d197bb0d8d 100644
--- a/assets/js/lib/api/validationErrors.test.js
+++ b/assets/js/lib/api/validationErrors.test.js
@@ -18,6 +18,18 @@ describe('hasError', () => {
expect(hasError('url', errors)).toBe(true);
});
+ it('should match subfields', () => {
+ const errors = [
+ {
+ detail: "can't be blank",
+ source: { pointer: '/date/year' },
+ title: 'Invalid value',
+ },
+ ];
+
+ expect(hasError('date/year', errors)).toBe(true);
+ });
+
it('should spot nothing in an empty list', () => {
expect(hasError('url', [])).toBe(false);
});
diff --git a/assets/js/lib/test-utils/factories/activityLogsSettings.js b/assets/js/lib/test-utils/factories/activityLogsSettings.js
new file mode 100644
index 0000000000..a3b1123643
--- /dev/null
+++ b/assets/js/lib/test-utils/factories/activityLogsSettings.js
@@ -0,0 +1,9 @@
+import { faker } from '@faker-js/faker';
+import { Factory } from 'fishery';
+
+export const activityLogsSettingsFactory = Factory.define(() => ({
+ retention_time: {
+ value: faker.number.int({ min: 1, max: 30 }),
+ unit: faker.helpers.arrayElement(['day', 'week', 'month', 'year']),
+ },
+}));
diff --git a/assets/js/pages/SettingsPage/SettingsPage.jsx b/assets/js/pages/SettingsPage/SettingsPage.jsx
index 9b31730b78..2160711f6a 100644
--- a/assets/js/pages/SettingsPage/SettingsPage.jsx
+++ b/assets/js/pages/SettingsPage/SettingsPage.jsx
@@ -33,6 +33,19 @@ import {
getSoftwareUpdatesSettingsErrors,
} from '@state/selectors/softwareUpdatesSettings';
+import ActivityLogsConfig from '@common/ActivityLogsConfig';
+import ActivityLogsSettingsModal from '@common/ActivityLogsSettingsModal';
+import {
+ fetchActivityLogsSettings,
+ updateActivityLogsSettings,
+ setEditingActivityLogsSettings,
+ setActivityLogsSettingsErrors,
+} from '@state/activityLogsSettings';
+import {
+ getActivityLogsSettings,
+ getActivityLogsSettingsErrors,
+} from '@state/selectors/activityLogsSettings';
+
import { dismissNotification } from '@state/notifications';
import { API_KEY_EXPIRATION_NOTIFICATION_ID } from '@state/sagas/settings';
@@ -102,6 +115,7 @@ function SettingsPage() {
setLoading(true);
fetchApiKeySettings();
dispatch(fetchSoftwareUpdatesSettings());
+ dispatch(fetchActivityLogsSettings());
}, []);
const {
@@ -120,6 +134,17 @@ function SettingsPage() {
(value) => !isUndefined(value)
);
+ const {
+ settings: activityLogsSettings = {},
+ loading: activityLogsSettingsLoading,
+ editing: editingActivityLogsSettings,
+ networkError: activityLogsSettingsNetworkError,
+ } = useSelector(getActivityLogsSettings);
+
+ const activityLogsValidationErrors = useSelector(
+ getActivityLogsSettingsErrors
+ );
+
const hasApiKey = Boolean(apiKey);
return (
@@ -305,6 +330,36 @@ function SettingsPage() {
/>
)}
+
+ dispatch(fetchActivityLogsSettings())}
+ >
+ dispatch(setEditingActivityLogsSettings(true))}
+ />
+
+ {
+ dispatch(updateActivityLogsSettings(payload));
+ }}
+ onCancel={() => {
+ dispatch(setActivityLogsSettingsErrors([]));
+ dispatch(setEditingActivityLogsSettings(false));
+ }}
+ />
);
}
diff --git a/assets/js/pages/SettingsPage/SettingsPage.test.jsx b/assets/js/pages/SettingsPage/SettingsPage.test.jsx
index 98b76007be..2e75a3f313 100644
--- a/assets/js/pages/SettingsPage/SettingsPage.test.jsx
+++ b/assets/js/pages/SettingsPage/SettingsPage.test.jsx
@@ -6,7 +6,7 @@ import '@testing-library/jest-dom';
import {
withState,
- defaultInitialState,
+ defaultInitialState as defaultInitialStateBase,
renderWithRouter,
} from '@lib/test-utils';
import { softwareUpdatesSettingsFactory } from '@lib/test-utils/factories/softwareUpdatesSettings';
@@ -17,6 +17,22 @@ import SettingsPage from './SettingsPage';
const axiosMock = new MockAdapter(networkClient);
+const defaultInitialState = {
+ ...defaultInitialStateBase,
+ softwareUpdatesSettings: {
+ settings: {
+ url: undefined,
+ username: undefined,
+ ca_uploaded_at: undefined,
+ },
+ },
+ activityLogsSettings: {
+ settings: {
+ retention_time: { value: 1, unit: 'day' },
+ },
+ },
+};
+
describe('Settings Page', () => {
afterEach(() => {
axiosMock.reset();
@@ -29,105 +45,194 @@ describe('Settings Page', () => {
});
});
- it('should render the api key with copy button', async () => {
- const [StatefulSettings] = withState(, {
- ...defaultInitialState,
- softwareUpdatesSettings: {
- loading: true,
- settings: {
- url: undefined,
- username: undefined,
- ca_uploaded_at: undefined,
+ describe('API Key Section', () => {
+ it('should render the api key with copy button', async () => {
+ const [StatefulSettings] = withState(, {
+ ...defaultInitialState,
+ softwareUpdatesSettings: {
+ loading: true,
+ settings: {
+ url: undefined,
+ username: undefined,
+ ca_uploaded_at: undefined,
+ },
},
- },
+ });
+
+ await act(async () => {
+ renderWithRouter(StatefulSettings);
+ });
+
+ expect(screen.getByText('Key will never expire')).toBeVisible();
+ expect(screen.getByText('api_key')).toBeVisible();
+ expect(
+ screen.getByRole('button', { name: 'copy to clipboard' })
+ ).toBeVisible();
});
+ });
+
+ describe('Software Updates Section', () => {
+ it('should render a loading box while fetching settings', async () => {
+ const [StatefulSettings] = withState(, {
+ ...defaultInitialState,
+ softwareUpdatesSettings: {
+ loading: true,
+ settings: {
+ url: undefined,
+ username: undefined,
+ ca_uploaded_at: undefined,
+ },
+ },
+ });
- await act(async () => {
renderWithRouter(StatefulSettings);
- });
- expect(screen.getByText('Key will never expire')).toBeVisible();
- expect(screen.getByText('api_key')).toBeVisible();
- expect(
- screen.getByRole('button', { name: 'copy to clipboard' })
- ).toBeVisible();
- });
+ expect(
+ screen.getByText('Loading SUSE Manager Settings...')
+ ).toBeVisible();
+ });
- it('should render a loading box while fetching settings', async () => {
- const [StatefulSettings] = withState(, {
- ...defaultInitialState,
- softwareUpdatesSettings: {
- loading: true,
- settings: {
- url: undefined,
- username: undefined,
- ca_uploaded_at: undefined,
+ it('should render an empty SUSE Manager Config Section', async () => {
+ const [StatefulSettings] = withState(, {
+ ...defaultInitialState,
+ softwareUpdatesSettings: {
+ loading: false,
+ settings: {
+ url: undefined,
+ username: undefined,
+ ca_uploaded_at: undefined,
+ },
+ error: null,
},
- },
+ });
+
+ renderWithRouter(StatefulSettings);
+
+ expect(screen.getByText('SUSE Manager URL')).toBeVisible();
+ expect(screen.getByText('https://')).toBeVisible();
+
+ expect(screen.getByText('CA Certificate')).toBeVisible();
+ expect(screen.getByText('-')).toBeVisible();
+
+ expect(screen.getByText('Username')).toBeVisible();
+ expect(screen.getByText('Password')).toBeVisible();
+
+ expect(screen.queryAllByText('.....')).toHaveLength(2);
});
- renderWithRouter(StatefulSettings);
+ it('should render SUSE Manager Config Section with configured settings', async () => {
+ const settings = softwareUpdatesSettingsFactory.build();
+
+ const [StatefulSettings] = withState(, {
+ ...defaultInitialState,
+ softwareUpdatesSettings: {
+ loading: false,
+ settings,
+ error: null,
+ },
+ });
+
+ const { url, username, ca_uploaded_at } = settings;
+
+ renderWithRouter(StatefulSettings);
+
+ expect(screen.getByText('SUSE Manager URL')).toBeVisible();
+ expect(screen.getByText(url)).toBeVisible();
+
+ expect(screen.getByText('CA Certificate')).toBeVisible();
+ expect(screen.getByText('Certificate Uploaded')).toBeVisible();
+ expect(
+ screen.getByText(format(ca_uploaded_at, "'Uploaded:' dd MMM y"))
+ ).toBeVisible();
- expect(screen.getByText('Loading SUSE Manager Settings...')).toBeVisible();
+ expect(screen.getByText('Username')).toBeVisible();
+ expect(screen.getByText(username)).toBeVisible();
+
+ expect(screen.getByText('Password')).toBeVisible();
+ expect(screen.getByText('•••••')).toBeVisible();
+ });
});
- it('should render an empty SUSE Manager Config Section', async () => {
- const [StatefulSettings] = withState(, {
- ...defaultInitialState,
- softwareUpdatesSettings: {
- loading: false,
- settings: {
- url: undefined,
- username: undefined,
- ca_uploaded_at: undefined,
+ describe('Activity Logs Section', () => {
+ it('should render activity logs section', async () => {
+ const [StatefulSettings] = withState(, {
+ ...defaultInitialState,
+ activityLogsSettings: {
+ loading: false,
+ settings: { retention_time: { value: 1, unit: 'day' } },
},
- error: null,
- },
+ });
+
+ await act(async () => {
+ renderWithRouter(StatefulSettings);
+ });
+
+ expect(screen.getByText('Activity Logs')).toBeVisible();
});
- renderWithRouter(StatefulSettings);
+ it('should render loader on activity logs section', async () => {
+ const [StatefulSettings] = withState(, {
+ ...defaultInitialState,
+ activityLogsSettings: {
+ loading: true,
+ settings: { retention_time: { value: 1, unit: 'day' } },
+ },
+ });
- expect(screen.getByText('SUSE Manager URL')).toBeVisible();
- expect(screen.getByText('https://')).toBeVisible();
+ await act(async () => {
+ renderWithRouter(StatefulSettings);
+ });
- expect(screen.getByText('CA Certificate')).toBeVisible();
- expect(screen.getByText('-')).toBeVisible();
+ expect(
+ screen.getByText('Loading Activity Logs Settings...')
+ ).toBeVisible();
+ });
- expect(screen.getByText('Username')).toBeVisible();
- expect(screen.getByText('Password')).toBeVisible();
+ it('should render edit modal', async () => {
+ const [StatefulSettings] = withState(, {
+ ...defaultInitialState,
+ activityLogsSettings: {
+ editing: true,
+ settings: { retention_time: { value: 1, unit: 'day' } },
+ },
+ });
- expect(screen.queryAllByText('.....')).toHaveLength(2);
- });
+ await act(async () => {
+ renderWithRouter(StatefulSettings);
+ });
- it('should render SUSE Manager Config Section with configured settings', async () => {
- const settings = softwareUpdatesSettingsFactory.build();
+ expect(screen.getByText('Enter Activity Logs Settings')).toBeVisible();
- const [StatefulSettings] = withState(, {
- ...defaultInitialState,
- softwareUpdatesSettings: {
- loading: false,
- settings,
- error: null,
- },
- });
+ expect(screen.getByText('1')).toBeVisible();
- const { url, username, ca_uploaded_at } = settings;
+ expect(screen.getByText('day')).toBeVisible();
- renderWithRouter(StatefulSettings);
+ expect(screen.getByText('Save Settings')).toBeVisible();
- expect(screen.getByText('SUSE Manager URL')).toBeVisible();
- expect(screen.getByText(url)).toBeVisible();
+ expect(screen.getByText('Cancel')).toBeVisible();
+ });
- expect(screen.getByText('CA Certificate')).toBeVisible();
- expect(screen.getByText('Certificate Uploaded')).toBeVisible();
- expect(
- screen.getByText(format(ca_uploaded_at, "'Uploaded:' dd MMM y"))
- ).toBeVisible();
+ it('should render saving errors', async () => {
+ const [StatefulSettings] = withState(, {
+ ...defaultInitialState,
+ activityLogsSettings: {
+ editing: true,
+ settings: { retention_time: { value: 1, unit: 'day' } },
+ errors: [
+ {
+ detail: 'Invalid data provided',
+ source: { pointer: '/retention_time/value' },
+ title: 'Invalid data',
+ },
+ ],
+ },
+ });
- expect(screen.getByText('Username')).toBeVisible();
- expect(screen.getByText(username)).toBeVisible();
+ await act(async () => {
+ renderWithRouter(StatefulSettings);
+ });
- expect(screen.getByText('Password')).toBeVisible();
- expect(screen.getByText('•••••')).toBeVisible();
+ expect(screen.getByText('Invalid data provided')).toBeVisible();
+ });
});
});
diff --git a/assets/js/state/activityLogsSettings.js b/assets/js/state/activityLogsSettings.js
new file mode 100644
index 0000000000..56b01d35b3
--- /dev/null
+++ b/assets/js/state/activityLogsSettings.js
@@ -0,0 +1,59 @@
+import { createAction, createSlice } from '@reduxjs/toolkit';
+
+const emptySettings = {
+ retention_time: undefined,
+};
+
+const initialState = {
+ loading: true,
+ settings: emptySettings,
+ networkError: false,
+ editing: false,
+ errors: [],
+};
+export const activityLogsSettingsSlice = createSlice({
+ name: 'activityLogsSettings',
+ initialState,
+ reducers: {
+ startLoadingActivityLogsSettings: (state) => {
+ state.loading = true;
+ },
+ setActivityLogsSettings: (state, { payload: settings }) => {
+ state.loading = false;
+ state.networkError = false;
+ state.settings = settings;
+ },
+ setActivityLogsSettingsErrors: (state, { payload: errors }) => {
+ state.loading = false;
+ state.networkError = false;
+ state.errors = errors;
+ },
+ setEditingActivityLogsSettings: (state, { payload }) => {
+ state.editing = payload;
+ },
+
+ setNetworkError: (state, { payload }) => {
+ state.loading = false;
+ state.networkError = payload;
+ },
+ },
+});
+
+export const FETCH_ACTIVITY_LOGS_SETTINGS = 'FETCH_ACTIVITY_LOGS_SETTINGS';
+export const UPDATE_ACTIVITY_LOGS_SETTINGS = 'UPDATE_ACTIVITY_LOGS_SETTINGS';
+
+export const fetchActivityLogsSettings = createAction(
+ FETCH_ACTIVITY_LOGS_SETTINGS
+);
+export const updateActivityLogsSettings = createAction(
+ UPDATE_ACTIVITY_LOGS_SETTINGS
+);
+export const {
+ startLoadingActivityLogsSettings,
+ setActivityLogsSettings,
+ setActivityLogsSettingsErrors,
+ setEditingActivityLogsSettings,
+ setNetworkError,
+} = activityLogsSettingsSlice.actions;
+
+export default activityLogsSettingsSlice.reducer;
diff --git a/assets/js/state/activityLogsSettings.test.js b/assets/js/state/activityLogsSettings.test.js
new file mode 100644
index 0000000000..3bdc0c9e38
--- /dev/null
+++ b/assets/js/state/activityLogsSettings.test.js
@@ -0,0 +1,128 @@
+import activityLogsSettingsReducer, {
+ startLoadingActivityLogsSettings,
+ setActivityLogsSettings,
+ setActivityLogsSettingsErrors,
+ setEditingActivityLogsSettings,
+ setNetworkError,
+} from './activityLogsSettings';
+
+describe('ActivityLogsSettings reducer', () => {
+ it('should mark software updates settings on loading state', () => {
+ const initialState = {
+ loading: false,
+ };
+
+ const action = startLoadingActivityLogsSettings();
+
+ const expectedState = {
+ loading: true,
+ };
+
+ expect(activityLogsSettingsReducer(initialState, action)).toEqual(
+ expectedState
+ );
+ });
+
+ it('should set software updates settings', () => {
+ const initialState = {
+ loading: true,
+ settings: {
+ retention_time: undefined,
+ },
+ networkError: false,
+ errors: [],
+ };
+
+ const settings = {
+ retention_time: { value: 2, unit: 'week' },
+ };
+
+ const action = setActivityLogsSettings(settings);
+
+ const actual = activityLogsSettingsReducer(initialState, action);
+
+ expect(actual).toEqual({
+ loading: false,
+ settings,
+ networkError: false,
+ errors: [],
+ });
+ });
+
+ it('should set errors upon validation failed', () => {
+ const initialState = {
+ loading: false,
+ settings: {
+ retention_time: { value: 2, unit: 'week' },
+ },
+ networkError: false,
+ errors: [],
+ };
+
+ const errors = [
+ {
+ detail: "can't be blank",
+ source: { pointer: '/retention_time' },
+ title: 'Invalid value',
+ },
+ ];
+
+ const action = setActivityLogsSettingsErrors(errors);
+
+ const actual = activityLogsSettingsReducer(initialState, action);
+
+ expect(actual).toEqual({
+ loading: false,
+ settings: {
+ retention_time: { value: 2, unit: 'week' },
+ },
+ networkError: false,
+ errors,
+ });
+ });
+
+ it('should set the editing field to true', () => {
+ const initialState = {
+ loading: false,
+ settings: {
+ retention_time: { value: 2, unit: 'week' },
+ },
+ networkError: false,
+ errors: [],
+ editing: false,
+ };
+
+ const action = setEditingActivityLogsSettings(true);
+
+ const actual = activityLogsSettingsReducer(initialState, action);
+
+ expect(actual).toEqual({
+ ...initialState,
+ editing: true,
+ });
+ });
+
+ it('should mark loading false on received network error', () => {
+ const initialState = {
+ loading: true,
+ settings: {
+ retention_time: { value: 2, unit: 'week' },
+ },
+ networkError: false,
+ errors: [],
+ editing: false,
+ };
+
+ [true, false].forEach((hasNetworkError) => {
+ const action = setNetworkError(hasNetworkError);
+
+ const actual = activityLogsSettingsReducer(initialState, action);
+
+ expect(actual).toEqual({
+ ...initialState,
+ loading: false,
+ networkError: hasNetworkError,
+ });
+ });
+ });
+});
diff --git a/assets/js/state/index.js b/assets/js/state/index.js
index 008e765aca..117b5f9e0e 100644
--- a/assets/js/state/index.js
+++ b/assets/js/state/index.js
@@ -14,6 +14,7 @@ import settingsReducer from './settings';
import userReducer from './user';
import softwareUpdatesReducer from './softwareUpdates';
import softwareUpdatesSettingsReducer from './softwareUpdatesSettings';
+import activityLogsSettingsReducer from './activityLogsSettings';
import rootSaga from './sagas';
export const createStore = (router) => {
@@ -38,6 +39,7 @@ export const createStore = (router) => {
user: userReducer,
softwareUpdates: softwareUpdatesReducer,
softwareUpdatesSettings: softwareUpdatesSettingsReducer,
+ activityLogsSettings: activityLogsSettingsReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(sagaMiddleware),
diff --git a/assets/js/state/sagas/activityLogsSettings.js b/assets/js/state/sagas/activityLogsSettings.js
new file mode 100644
index 0000000000..6b9e0d5e25
--- /dev/null
+++ b/assets/js/state/sagas/activityLogsSettings.js
@@ -0,0 +1,46 @@
+import { get } from 'lodash';
+import { put, call, takeEvery } from 'redux-saga/effects';
+import { getSettings, updateSettings } from '@lib/api/activityLogsSettings';
+
+import {
+ FETCH_ACTIVITY_LOGS_SETTINGS,
+ UPDATE_ACTIVITY_LOGS_SETTINGS,
+ startLoadingActivityLogsSettings,
+ setActivityLogsSettings,
+ setActivityLogsSettingsErrors,
+ setEditingActivityLogsSettings,
+ setNetworkError,
+} from '@state/activityLogsSettings';
+
+export function* fetchActivityLogsSettings() {
+ yield put(startLoadingActivityLogsSettings());
+
+ try {
+ const response = yield call(getSettings);
+ yield put(setActivityLogsSettings(response.data));
+ } catch (error) {
+ yield put(setNetworkError(true));
+ }
+}
+
+export function* updateActivityLogsSettings({ payload }) {
+ yield put(startLoadingActivityLogsSettings());
+ try {
+ const response = yield call(updateSettings, payload);
+ yield put(setActivityLogsSettings(response.data));
+ yield put(setEditingActivityLogsSettings(false));
+ yield put(setActivityLogsSettingsErrors([]));
+ } catch (error) {
+ const errors = get(
+ error,
+ ['response', 'data', 'errors'],
+ ['An error occurred while saving the settings']
+ );
+ yield put(setActivityLogsSettingsErrors(errors));
+ }
+}
+
+export function* watchActivityLogsSettings() {
+ yield takeEvery(FETCH_ACTIVITY_LOGS_SETTINGS, fetchActivityLogsSettings);
+ yield takeEvery(UPDATE_ACTIVITY_LOGS_SETTINGS, updateActivityLogsSettings);
+}
diff --git a/assets/js/state/sagas/activityLogsSettings.test.js b/assets/js/state/sagas/activityLogsSettings.test.js
new file mode 100644
index 0000000000..32dd163e05
--- /dev/null
+++ b/assets/js/state/sagas/activityLogsSettings.test.js
@@ -0,0 +1,115 @@
+import { recordSaga } from '@lib/test-utils';
+
+import { networkClient } from '@lib/network';
+import MockAdapter from 'axios-mock-adapter';
+
+import { activityLogsSettingsFactory } from '@lib/test-utils/factories/activityLogsSettings';
+
+import {
+ startLoadingActivityLogsSettings,
+ setActivityLogsSettings,
+ setActivityLogsSettingsErrors,
+ setEditingActivityLogsSettings,
+ setNetworkError,
+} from '@state/activityLogsSettings';
+
+import {
+ fetchActivityLogsSettings,
+ updateActivityLogsSettings,
+} from './activityLogsSettings';
+
+describe('Activity Logs Settings saga', () => {
+ describe('Fetching Activity Logs Settings', () => {
+ it('should successfully fetch activity logs settings', async () => {
+ const axiosMock = new MockAdapter(networkClient);
+ const successfulResponse = activityLogsSettingsFactory.build();
+
+ axiosMock.onGet('/settings/activity_log').reply(200, successfulResponse);
+
+ const dispatched = await recordSaga(fetchActivityLogsSettings);
+
+ expect(dispatched).toEqual([
+ startLoadingActivityLogsSettings(),
+ setActivityLogsSettings(successfulResponse),
+ ]);
+ });
+
+ it.each([403, 404, 500, 502, 504])(
+ 'should put a network error flag on failed fetching',
+ async (status) => {
+ const axiosMock = new MockAdapter(networkClient);
+ axiosMock.onGet('/settings/activity_log').reply(status);
+
+ const dispatched = await recordSaga(fetchActivityLogsSettings);
+
+ expect(dispatched).toEqual([
+ startLoadingActivityLogsSettings(),
+ setNetworkError(true),
+ ]);
+ }
+ );
+ });
+
+ describe('Updating Activity Logs settings', () => {
+ it('should successfully change activity logs settings', async () => {
+ const axiosMock = new MockAdapter(networkClient);
+ const payload = activityLogsSettingsFactory.build();
+
+ axiosMock.onPut('/settings/activity_log').reply(200, payload);
+
+ const dispatched = await recordSaga(updateActivityLogsSettings, payload);
+
+ expect(dispatched).toEqual([
+ startLoadingActivityLogsSettings(),
+ setActivityLogsSettings(payload),
+ setEditingActivityLogsSettings(false),
+ setActivityLogsSettingsErrors([]),
+ ]);
+ });
+
+ it('should have errors on failed update', async () => {
+ const axiosMock = new MockAdapter(networkClient);
+ const payload = activityLogsSettingsFactory.build();
+
+ const errors = [
+ {
+ detail: "can't be blank",
+ source: { pointer: '/retention_time/value' },
+ title: 'Invalid value',
+ },
+ ];
+
+ axiosMock.onPut('/settings/activity_log', payload).reply(422, {
+ errors,
+ });
+
+ const dispatched = await recordSaga(updateActivityLogsSettings, {
+ payload,
+ });
+
+ expect(dispatched).toEqual([
+ startLoadingActivityLogsSettings(),
+ setActivityLogsSettingsErrors(errors),
+ ]);
+ });
+
+ it.each([403, 404, 500, 502, 504])(
+ 'should put a network error flag on failed saving',
+ async (status) => {
+ const axiosMock = new MockAdapter(networkClient);
+ axiosMock.onPut('/settings/activity_log').reply(status);
+
+ const payload = activityLogsSettingsFactory.build();
+
+ const dispatched = await recordSaga(updateActivityLogsSettings, {
+ payload,
+ });
+
+ expect(dispatched).toEqual([
+ startLoadingActivityLogsSettings(),
+ setActivityLogsSettingsErrors([expect.any(String)]),
+ ]);
+ }
+ );
+ });
+});
diff --git a/assets/js/state/sagas/index.js b/assets/js/state/sagas/index.js
index 1b8c10f87f..d969a03746 100644
--- a/assets/js/state/sagas/index.js
+++ b/assets/js/state/sagas/index.js
@@ -77,6 +77,7 @@ import {
} from '@state/sagas/user';
import { watchChecksSelectionEvents } from '@state/sagas/checksSelection';
import { watchSoftwareUpdateSettings } from '@state/sagas/softwareUpdatesSettings';
+import { watchActivityLogsSettings } from '@state/sagas/activityLogsSettings';
import { watchSoftwareUpdates } from '@state/sagas/softwareUpdates';
import { watchSocketEvents } from '@state/sagas/channels';
@@ -248,6 +249,7 @@ export default function* rootSaga() {
watchSapSystemEvents(),
watchUserLoggedIn(),
watchSoftwareUpdateSettings(),
+ watchActivityLogsSettings(),
watchSoftwareUpdates(),
]);
}
diff --git a/assets/js/state/selectors/activityLogsSettings.js b/assets/js/state/selectors/activityLogsSettings.js
new file mode 100644
index 0000000000..3e97eda7c1
--- /dev/null
+++ b/assets/js/state/selectors/activityLogsSettings.js
@@ -0,0 +1,17 @@
+import { createSelector } from '@reduxjs/toolkit';
+
+export const getActivityLogsSettings = createSelector(
+ [({ activityLogsSettings }) => activityLogsSettings],
+ ({ settings, errors, loading, editing, networkError }) => ({
+ settings,
+ errors,
+ loading,
+ editing,
+ networkError,
+ })
+);
+
+export const getActivityLogsSettingsErrors = createSelector(
+ [getActivityLogsSettings],
+ ({ errors }) => errors
+);
diff --git a/assets/js/state/selectors/activityLogsSettings.test.js b/assets/js/state/selectors/activityLogsSettings.test.js
new file mode 100644
index 0000000000..fb00ffdb87
--- /dev/null
+++ b/assets/js/state/selectors/activityLogsSettings.test.js
@@ -0,0 +1,33 @@
+import { getActivityLogsSettings } from './activityLogsSettings';
+
+describe('Activity Logs Settings selector', () => {
+ describe('get activity logs settings', () => {
+ const stateScenarios = [
+ {
+ loading: false,
+ settings: {
+ retention_time: { value: 2, unit: 'week' },
+ },
+ errors: null,
+ editing: false,
+ },
+ {
+ loading: true,
+ settings: {
+ retention_time: undefined,
+ },
+ errors: null,
+ editing: false,
+ },
+ ];
+
+ it.each(stateScenarios)(
+ 'should return the correct catalog state',
+ (activityLogsSettings) => {
+ expect(getActivityLogsSettings({ activityLogsSettings })).toEqual(
+ activityLogsSettings
+ );
+ }
+ );
+ });
+});