From 5af8fac00fdefe3c66ddf0a15d05b358c763ed2a Mon Sep 17 00:00:00 2001 From: Emanuele De Cupis Date: Wed, 3 Jul 2024 15:21:57 +0200 Subject: [PATCH] Activity log settings UI state (#2727) --- .../ActivityLogsSettingsModal.jsx | 48 +++- .../ActivityLogsSettingsModal.stories.jsx | 33 ++- .../ActivityLogsSettingsModal.test.jsx | 72 +++-- assets/js/lib/api/activityLogsSettings.js | 6 + assets/js/lib/api/validationErrors.test.js | 12 + .../factories/activityLogsSettings.js | 9 + assets/js/pages/SettingsPage/SettingsPage.jsx | 55 ++++ .../pages/SettingsPage/SettingsPage.test.jsx | 253 +++++++++++++----- assets/js/state/activityLogsSettings.js | 59 ++++ assets/js/state/activityLogsSettings.test.js | 128 +++++++++ assets/js/state/index.js | 2 + assets/js/state/sagas/activityLogsSettings.js | 46 ++++ .../state/sagas/activityLogsSettings.test.js | 115 ++++++++ assets/js/state/sagas/index.js | 2 + .../state/selectors/activityLogsSettings.js | 17 ++ .../selectors/activityLogsSettings.test.js | 33 +++ 16 files changed, 772 insertions(+), 118 deletions(-) create mode 100644 assets/js/lib/api/activityLogsSettings.js create mode 100644 assets/js/lib/test-utils/factories/activityLogsSettings.js create mode 100644 assets/js/state/activityLogsSettings.js create mode 100644 assets/js/state/activityLogsSettings.test.js create mode 100644 assets/js/state/sagas/activityLogsSettings.js create mode 100644 assets/js/state/sagas/activityLogsSettings.test.js create mode 100644 assets/js/state/selectors/activityLogsSettings.js create mode 100644 assets/js/state/selectors/activityLogsSettings.test.js 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 + ); + } + ); + }); +});