From 6fd08ae80394a8642a9d27c774debf518ba323db Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Fri, 27 Sep 2024 15:28:37 +0200 Subject: [PATCH 1/9] chore: cleaned up remnants of artifact_name deprecation Signed-off-by: Manuel Zedel --- .../devices/dialogs/custom-columns-dialog-content.test.js | 2 +- .../components/devices/dialogs/custom-columns-dialog.test.js | 2 +- .../js/components/devices/widgets/attribute-autocomplete.js | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/src/js/components/devices/dialogs/custom-columns-dialog-content.test.js b/frontend/src/js/components/devices/dialogs/custom-columns-dialog-content.test.js index 4456981c..5d8bc69d 100644 --- a/frontend/src/js/components/devices/dialogs/custom-columns-dialog-content.test.js +++ b/frontend/src/js/components/devices/dialogs/custom-columns-dialog-content.test.js @@ -45,7 +45,7 @@ describe('ColumnCustomizationDialogContent Component', () => { name: rootfs, scope: 'inventory', title: 'Current software', - attribute: { name: rootfs, scope: 'inventory', alternative: 'artifact_name' }, + attribute: { name: rootfs, scope: 'inventory' }, textRender: jest.fn }, { diff --git a/frontend/src/js/components/devices/dialogs/custom-columns-dialog.test.js b/frontend/src/js/components/devices/dialogs/custom-columns-dialog.test.js index b9818e09..79ee41bd 100644 --- a/frontend/src/js/components/devices/dialogs/custom-columns-dialog.test.js +++ b/frontend/src/js/components/devices/dialogs/custom-columns-dialog.test.js @@ -45,7 +45,7 @@ describe('ColumnCustomizationDialog Component', () => { name: rootfs, scope: 'inventory', title: 'Current software', - attribute: { name: rootfs, scope: 'inventory', alternative: 'artifact_name' }, + attribute: { name: rootfs, scope: 'inventory' }, textRender: jest.fn }, { diff --git a/frontend/src/js/components/devices/widgets/attribute-autocomplete.js b/frontend/src/js/components/devices/widgets/attribute-autocomplete.js index 0adc8548..4066f46f 100644 --- a/frontend/src/js/components/devices/widgets/attribute-autocomplete.js +++ b/frontend/src/js/components/devices/widgets/attribute-autocomplete.js @@ -24,9 +24,7 @@ import { getFilterLabelByKey } from './filters'; const textFieldStyle = { marginTop: 0, marginBottom: 15 }; export const getOptionLabel = option => { - const header = Object.values(defaultHeaders).find( - ({ attribute }) => attribute.scope === option.scope && (attribute.name === option.key || attribute.alternative === option.key) - ); + const header = Object.values(defaultHeaders).find(({ attribute }) => attribute.scope === option.scope && attribute.name === option.key); return header?.title || option.title || option.value || option.key || option; }; From eb0eeb6fef9f67e74e6450f0475606260ffa6cbe Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Thu, 27 Jun 2024 15:25:00 +0200 Subject: [PATCH 2/9] fix: fixed regression that broke state based navigation for all post-init interactions Ticket: None Changelog: None Signed-off-by: Manuel Zedel --- frontend/src/js/components/devices/device-groups.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/frontend/src/js/components/devices/device-groups.js b/frontend/src/js/components/devices/device-groups.js index 9a3e6be8..d16aa5d8 100644 --- a/frontend/src/js/components/devices/device-groups.js +++ b/frontend/src/js/components/devices/device-groups.js @@ -13,7 +13,7 @@ // limitations under the License. import React, { useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { useLocation, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { AddCircle as AddIcon } from '@mui/icons-material'; import { Dialog, DialogContent, DialogTitle } from '@mui/material'; @@ -98,7 +98,6 @@ export const DeviceGroups = () => { const isEnterprise = useSelector(getIsEnterprise); const dispatch = useDispatch(); const isInitialized = useRef(false); - const location = useLocation(); const [locationParams, setLocationParams] = useLocationParams('devices', { filteringAttributes, @@ -128,11 +127,6 @@ export const DeviceGroups = () => { setLocationParams ]); - useEffect(() => { - // set isInitialized ref to false when location changes, otherwise when you go back setLocationParams will be set with a duplicate item - isInitialized.current = false; - }, [location]); - useEffect(() => { const { groupName, filters = [], id = [], ...remainder } = locationParams; const { hasFullFiltering } = tenantCapabilities; From 44cc3d70d1c624f8a59c0cdb743c6d23d6bacc6c Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Thu, 27 Jun 2024 15:14:40 +0200 Subject: [PATCH 3/9] chore: added unified sort icon to increase alignment in the UI & support asc/desc/none sorting Signed-off-by: Manuel Zedel --- frontend/src/js/components/common/sorticon.js | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 frontend/src/js/components/common/sorticon.js diff --git a/frontend/src/js/components/common/sorticon.js b/frontend/src/js/components/common/sorticon.js new file mode 100644 index 00000000..3f2114dd --- /dev/null +++ b/frontend/src/js/components/common/sorticon.js @@ -0,0 +1,39 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import React, { useEffect, useRef, useState } from 'react'; + +// material ui +import { Sort } from '@mui/icons-material'; + +import { TIMEOUTS } from '../../constants/appConstants'; + +const SortIcon = ({ columnKey, disabled = false, sortDown = false }) => { + const timer = useRef(); + const [fadeIcon, setFadeIcon] = useState(true); + + useEffect(() => { + if (disabled) { + timer.current = setTimeout(() => setFadeIcon(true), TIMEOUTS.oneSecond); + } else { + timer.current = setTimeout(() => setFadeIcon(false), TIMEOUTS.debounceShort); + } + return () => { + clearTimeout(timer.current); + }; + }, [disabled]); + + return ; +}; + +export default SortIcon; From 480cf93e63632ed5ca517f3643c616357d54a69d Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Thu, 27 Jun 2024 15:26:17 +0200 Subject: [PATCH 4/9] feat: added url support for multiple sorting criteria Ticket: MEN-7169 Changelog: None Signed-off-by: Manuel Zedel --- frontend/src/js/utils/locationutils.js | 47 +++++++++++++-------- frontend/src/js/utils/locationutils.test.js | 32 +++++++++++++- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/frontend/src/js/utils/locationutils.js b/frontend/src/js/utils/locationutils.js index b3a5ecef..37c3cb72 100644 --- a/frontend/src/js/utils/locationutils.js +++ b/frontend/src/js/utils/locationutils.js @@ -37,6 +37,8 @@ const commonFields = { issues: { parse: undefined, select: defaultSelector, target: 'selectedIssues' }, open: { parse: Boolean, select: defaultSelector, target: 'open' } }; +const sortingFields = ['scope', 'key', 'direction']; +const parsingSortingFields = [...sortingFields].reverse(); const scopes = { identity: { delimiter: 'identity', filters: [] }, @@ -67,15 +69,18 @@ export const commonProcessor = searchParams => { Object.keys(commonFields).map(key => params.delete(key)); const sort = params.has('sort') ? params.getAll('sort').reduce((sortAccu, scopedQuery) => { + // reverse the items to ensure the optional scope is only considered if it is present const items = scopedQuery.split(SEPARATOR).reverse(); - return ['direction', 'key', 'scope'].reduce((accu, key, index) => { + const parsedSortItem = parsingSortingFields.reduce((accu, key, index) => { if (items[index]) { accu[key] = items[index]; } return accu; - }, sortAccu); - }, {}) - : undefined; + }, {}); + sortAccu.push(parsedSortItem); + return sortAccu; + }, []) + : []; params.delete('sort'); return { pageState, params, sort }; }; @@ -156,23 +161,31 @@ export const parseDeviceQuery = (searchParams, extraProps = {}) => { return { detailsTab, filters: Object.values(scopedFilters).flat(), groupName, ...pageStateExtension }; }; -const formatSorting = (sort, { sort: sortDefault }) => { +const formatSorting = (sort, { sort: sortDefault = [] }) => { if (!sort || deepCompare(sort, sortDefault)) { return ''; } - const sortQuery = ['scope', 'key', 'direction'] - .reduce((accu, key) => { - if (!sort[key]) { - return accu; - } - accu.push(sort[key]); - return accu; - }, []) - .join(SEPARATOR); - return `sort=${sortQuery}`; + let sorter = sort; + if (!Array.isArray(sort)) { + sorter = [sort]; + } + const queries = sorter.reduce((accu, sortOption) => { + const sortQuery = sortingFields + .reduce((fieldsAccu, key) => { + if (!sortOption[key]) { + return fieldsAccu; + } + fieldsAccu.push(sortOption[key]); + return fieldsAccu; + }, []) + .join(SEPARATOR); + accu.push(`sort=${sortQuery}`); + return accu; + }, []); + return queries.join('&'); }; -export const formatPageState = ({ selectedId, selectedIssues, page, perPage, sort }, { defaults }) => +export const formatPageState = ({ selectedId, selectedIssues, page, perPage, sort }, { defaults = {} }) => Object.entries({ page, perPage, id: selectedId, issues: selectedIssues, open: selectedId ? true : undefined }) .reduce( (accu, [key, value]) => { @@ -312,7 +325,7 @@ const formatActiveDeployments = (pageState, { defaults }) => .filter(i => i) .join('&'); -export const formatDeployments = ({ deploymentObject, pageState }, { defaults, today, tonight }) => { +export const formatDeployments = ({ deploymentObject, pageState }, { defaults = {}, today, tonight }) => { const { state: selectedState, showCreationDialog } = pageState.general; let params = new URLSearchParams(); if (showCreationDialog) { diff --git a/frontend/src/js/utils/locationutils.test.js b/frontend/src/js/utils/locationutils.test.js index 4a8fdced..442a0bb9 100644 --- a/frontend/src/js/utils/locationutils.test.js +++ b/frontend/src/js/utils/locationutils.test.js @@ -37,9 +37,11 @@ const sortDefaults = { sort: { direction: 'asc' } }; describe('locationutils', () => { describe('common', () => { it('uses working utilties - commonProcessor', () => { - const startParams = new URLSearchParams('?perPage=234&id=123-324&open=true&sort=asc&issues=issueType1&issues=issueType2'); + const startParams = new URLSearchParams( + '?perPage=234&id=123-324&open=true&sort=desc&sort=someKey:asc&sort=scoped:otherKey:asc&issues=issueType1&issues=issueType2' + ); const { pageState, params, sort } = commonProcessor(startParams); - expect(sort).toEqual({ direction: 'asc' }); + expect(sort).toEqual([{ direction: 'desc' }, { direction: 'asc', key: 'someKey' }, { direction: 'asc', key: 'otherKey', scope: 'scoped' }]); expect(pageState).toEqual({ id: ['123-324'], selectedIssues: ['issueType1', 'issueType2'], open: true, perPage: 234 }); expect(params.has('page')).not.toBeTruthy(); }); @@ -50,6 +52,32 @@ describe('locationutils', () => { ); expect(search).toEqual('sort=someKey:desc&page=1234&perPage=1000&id=123&issues=1243&issues=qweioqwei&open=true'); }); + it('uses working utilities - formatPageState', () => { + const search = formatPageState( + { + page: 1234, + perPage: 1000, + sort: [ + { direction: 'desc', key: 'someKey' }, + { direction: 'asc', key: 'otherKey' }, + { direction: 'desc', scope: 'fooscope', key: 'someKey' } + ] + }, + { defaults: { sort: [{ direction: 'asc', key: 'otherKey' }] } } + ); + expect(search).toEqual('sort=someKey:desc&sort=otherKey:asc&sort=fooscope:someKey:desc&page=1234&perPage=1000'); + }); + it('uses working utilities - formatPageState with default sort', () => { + const search = formatPageState( + { + page: 1234, + perPage: 1000, + sort: [{ direction: 'asc', key: 'otherKey' }] + }, + { defaults: { sort: [{ direction: 'asc', key: 'otherKey' }] } } + ); + expect(search).toEqual('page=1234&perPage=1000'); + }); }); describe('auditlog', () => { const startDate = new Date('2000-01-01').toISOString(); From 9c5f700a26bddc705bfed02d4038727bf1ac191f Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Thu, 27 Jun 2024 15:36:04 +0200 Subject: [PATCH 5/9] chore: aligned sort state shape with updated url handling where possible Signed-off-by: Manuel Zedel --- .../src/js/components/auditlogs/auditlogs.js | 4 +-- .../js/components/devices/device-groups.js | 5 ++- .../src/js/components/releases/releases.js | 6 ++-- frontend/src/js/components/search-result.js | 3 +- frontend/src/js/store/appSlice/index.ts | 9 ++--- .../src/js/store/appSlice/reducer.test.ts | 4 +-- frontend/src/js/store/appSlice/thunks.ts | 9 ++--- .../js/store/deploymentsSlice/constants.ts | 5 ++- frontend/src/js/store/devicesSlice/index.ts | 16 +++------ frontend/src/js/store/devicesSlice/thunks.tsx | 13 ++++--- .../src/js/store/usersSlice/thunks.test.ts | 2 +- frontend/src/js/store/utils.ts | 34 ++++++++++++++++++- frontend/tests/mockData.js | 8 ++--- 13 files changed, 63 insertions(+), 55 deletions(-) diff --git a/frontend/src/js/components/auditlogs/auditlogs.js b/frontend/src/js/components/auditlogs/auditlogs.js index 92b2322f..bfb7db2a 100644 --- a/frontend/src/js/components/auditlogs/auditlogs.js +++ b/frontend/src/js/components/auditlogs/auditlogs.js @@ -77,8 +77,6 @@ const autoSelectProps = { renderOption }; -const locationDefaults = { sort: { direction: SORTING_OPTIONS.desc } }; - export const AuditLogs = props => { const [csvLoading, setCsvLoading] = useState(false); @@ -86,7 +84,7 @@ export const AuditLogs = props => { const { start: today, end: tonight } = date; const isInitialized = useRef(); - const [locationParams, setLocationParams] = useLocationParams('auditlogs', { today, tonight, defaults: locationDefaults }); + const [locationParams, setLocationParams] = useLocationParams('auditlogs', { today, tonight }); const { classes } = useStyles(); const dispatch = useDispatch(); const events = useSelector(getAuditLog); diff --git a/frontend/src/js/components/devices/device-groups.js b/frontend/src/js/components/devices/device-groups.js index d16aa5d8..269bb638 100644 --- a/frontend/src/js/components/devices/device-groups.js +++ b/frontend/src/js/components/devices/device-groups.js @@ -19,7 +19,7 @@ import { AddCircle as AddIcon } from '@mui/icons-material'; import { Dialog, DialogContent, DialogTitle } from '@mui/material'; import storeActions from '@northern.tech/store/actions'; -import { DEVICE_FILTERING_OPTIONS, DEVICE_ISSUE_OPTIONS, DEVICE_STATES, SORTING_OPTIONS, emptyFilter, onboardingSteps } from '@northern.tech/store/constants'; +import { DEVICE_FILTERING_OPTIONS, DEVICE_ISSUE_OPTIONS, DEVICE_STATES, emptyFilter, onboardingSteps } from '@northern.tech/store/constants'; import { getAcceptedDevices, getDeviceCountsByStatus, @@ -101,8 +101,7 @@ export const DeviceGroups = () => { const [locationParams, setLocationParams] = useLocationParams('devices', { filteringAttributes, - filters, - defaults: { sort: { direction: SORTING_OPTIONS.desc } } + filters }); const { refreshTrigger, selectedId, state: selectedState } = deviceListState; diff --git a/frontend/src/js/components/releases/releases.js b/frontend/src/js/components/releases/releases.js index 6bc8dbce..67005806 100644 --- a/frontend/src/js/components/releases/releases.js +++ b/frontend/src/js/components/releases/releases.js @@ -164,7 +164,7 @@ export const Releases = () => { const [selectedFile, setSelectedFile] = useState(); const [showAddArtifactDialog, setShowAddArtifactDialog] = useState(false); const artifactTimer = useRef(); - const [locationParams, setLocationParams] = useLocationParams('releases', { defaults: { direction: SORTING_OPTIONS.desc, key: 'modified' } }); + const [locationParams, setLocationParams] = useLocationParams('releases', { defaults: { sort: { direction: SORTING_OPTIONS.desc, key: 'modified' } } }); const debouncedSearchTerm = useDebounce(searchTerm, TIMEOUTS.debounceDefault); const debouncedTypeFilter = useDebounce(type, TIMEOUTS.debounceDefault); @@ -189,11 +189,11 @@ export const Releases = () => { ]); useEffect(() => { - const { selectedRelease, tags, ...remainder } = locationParams; + const { selectedRelease, tags, sort, ...remainder } = locationParams; if (selectedRelease) { dispatch(selectRelease(selectedRelease)); } - dispatch(setReleasesListState({ ...remainder, selectedTags: tags })); + dispatch(setReleasesListState({ ...remainder, sort: sort[0], selectedTags: tags })); clearInterval(artifactTimer.current); artifactTimer.current = setInterval(() => dispatch(getReleases()), refreshArtifactsLength); return () => { diff --git a/frontend/src/js/components/search-result.js b/frontend/src/js/components/search-result.js index 800346fe..d5cbf380 100644 --- a/frontend/src/js/components/search-result.js +++ b/frontend/src/js/components/search-result.js @@ -76,8 +76,7 @@ export const SearchResult = ({ onToggleSearchResult, open = true }) => { const [columnHeaders, setColumnHeaders] = useState(getHeaders(columnSelection, routes.devices.defaultHeaders, idAttribute)); - const { isSearching, searchTerm, searchTotal, sort = {} } = searchState; - const { direction: sortDown = SORTING_OPTIONS.desc, key: sortCol } = sort; + const { isSearching, searchTerm, searchTotal } = searchState; useEffect(() => { const columnHeaders = getHeaders(columnSelection, routes.devices.defaultHeaders, idAttribute); diff --git a/frontend/src/js/store/appSlice/index.ts b/frontend/src/js/store/appSlice/index.ts index 53c97643..27e83445 100644 --- a/frontend/src/js/store/appSlice/index.ts +++ b/frontend/src/js/store/appSlice/index.ts @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. // @ts-nocheck -import { SORTING_OPTIONS } from '@northern.tech/store/constants'; import { createSlice } from '@reduxjs/toolkit'; export const sliceName = 'app'; @@ -58,11 +57,9 @@ export const initialState = { deviceIds: [], searchTerm: '', searchTotal: 0, - sort: { - direction: SORTING_OPTIONS.desc - // key: null, - // scope: null - } + sort: [ + // { direction: AppConstants.SORTING_OPTIONS.desc, key: null, scope: null} + ] }, stripeAPIKey: '', trackerCode: '', diff --git a/frontend/src/js/store/appSlice/reducer.test.ts b/frontend/src/js/store/appSlice/reducer.test.ts index 09bc860e..2b81431c 100644 --- a/frontend/src/js/store/appSlice/reducer.test.ts +++ b/frontend/src/js/store/appSlice/reducer.test.ts @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. // @ts-nocheck -import { SORTING_OPTIONS } from '@northern.tech/store/commonConstants'; - import reducer, { actions, initialState } from '.'; const snackbarMessage = 'Run the tests'; @@ -21,7 +19,7 @@ const initialSearchState = { deviceIds: [], searchTerm: '', searchTotal: 0, - sort: { direction: SORTING_OPTIONS.desc } + sort: [] }; describe('app reducer', () => { diff --git a/frontend/src/js/store/appSlice/thunks.ts b/frontend/src/js/store/appSlice/thunks.ts index 78fd382f..17978b24 100644 --- a/frontend/src/js/store/appSlice/thunks.ts +++ b/frontend/src/js/store/appSlice/thunks.ts @@ -15,7 +15,7 @@ import GeneralApi from '@northern.tech/store/api/general-api'; import { getOfflineThresholdSettings } from '@northern.tech/store/selectors'; import { searchDevices } from '@northern.tech/store/thunks'; -import { extractErrorMessage, getComparisonCompatibleVersion } from '@northern.tech/store/utils'; +import { combineSortCriteria, extractErrorMessage, getComparisonCompatibleVersion, sortCriteriaToSortOptions } from '@northern.tech/store/utils'; import { createAsyncThunk } from '@reduxjs/toolkit'; import Cookies from 'universal-cookie'; @@ -121,10 +121,7 @@ export const setSearchState = createAsyncThunk(`${sliceName}/setSearchState`, (s let nextState = { ...currentState, ...searchState, - sort: { - ...currentState.sort, - ...searchState.sort - } + sort: combineSortCriteria(currentState.sort, searchState.sort) }; let tasks = []; // eslint-disable-next-line no-unused-vars @@ -134,7 +131,7 @@ export const setSearchState = createAsyncThunk(`${sliceName}/setSearchState`, (s if (nextRequestState.searchTerm && !deepCompare(currentRequestState, nextRequestState)) { nextState.isSearching = true; tasks.push( - dispatch(searchDevices(nextState)) + dispatch(searchDevices({ ...nextState, sortOptions: sortCriteriaToSortOptions(nextState.sort) })) .unwrap() .then(results => { const searchResult = results[results.length - 1]; diff --git a/frontend/src/js/store/deploymentsSlice/constants.ts b/frontend/src/js/store/deploymentsSlice/constants.ts index d05267b9..aa15ba34 100644 --- a/frontend/src/js/store/deploymentsSlice/constants.ts +++ b/frontend/src/js/store/deploymentsSlice/constants.ts @@ -11,7 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { DEVICE_LIST_DEFAULTS, SORTING_OPTIONS, apiUrl } from '@northern.tech/store/constants'; +import { DEVICE_LIST_DEFAULTS, apiUrl } from '@northern.tech/store/constants'; const alreadyInstalled = 'already-installed'; @@ -95,8 +95,7 @@ export const listDefaultsByState = { [DEPLOYMENT_STATES.inprogress]: { page: 1, perPage: 10 }, [DEPLOYMENT_STATES.pending]: { page: 1, perPage: 10 }, [DEPLOYMENT_STATES.scheduled]: { ...DEVICE_LIST_DEFAULTS }, - [DEPLOYMENT_STATES.finished]: { ...DEVICE_LIST_DEFAULTS }, - sort: { direction: SORTING_OPTIONS.desc } + [DEPLOYMENT_STATES.finished]: { ...DEVICE_LIST_DEFAULTS } }; export const DEFAULT_PENDING_INPROGRESS_COUNT = 10; diff --git a/frontend/src/js/store/devicesSlice/index.ts b/frontend/src/js/store/devicesSlice/index.ts index 5bd1ed08..adcbd2e4 100644 --- a/frontend/src/js/store/devicesSlice/index.ts +++ b/frontend/src/js/store/devicesSlice/index.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. // @ts-nocheck -import { DEVICE_LIST_DEFAULTS, SORTING_OPTIONS } from '@northern.tech/store/commonConstants'; +import { DEVICE_LIST_DEFAULTS } from '@northern.tech/store/commonConstants'; import { createSlice } from '@reduxjs/toolkit'; import { deepCompare, duplicateFilter } from '../../helpers'; @@ -41,11 +41,9 @@ export const initialState = { selectedAttributes: [], selectedIssues: [], selection: [], - sort: { - direction: SORTING_OPTIONS.desc - // key: null, - // scope: null - }, + sort: [ + // { direction: SORTING_OPTIONS.desc, key: null, scope: null } + ], state: DEVICE_STATES.accepted, total: 0 }, @@ -133,11 +131,7 @@ export const devicesSlice = createSlice({ setDeviceListState: (state, action) => { state.deviceList = { ...state.deviceList, - ...action.payload, - sort: { - ...state.deviceList.sort, - ...action.payload.sort - } + ...action.payload }; }, setFilterAttributes: (state, action) => { diff --git a/frontend/src/js/store/devicesSlice/thunks.tsx b/frontend/src/js/store/devicesSlice/thunks.tsx index 1086979c..82fda1a7 100644 --- a/frontend/src/js/store/devicesSlice/thunks.tsx +++ b/frontend/src/js/store/devicesSlice/thunks.tsx @@ -11,8 +11,8 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. - // @ts-nocheck + /*eslint import/namespace: ['error', { allowComputed: true }]*/ import React from 'react'; import { Link } from 'react-router-dom'; @@ -25,7 +25,6 @@ import { DEVICE_LIST_DEFAULTS, EXTERNAL_PROVIDER, MAX_PAGE_SIZE, - SORTING_OPTIONS, TIMEOUTS, UNGROUPED_GROUP, auditLogsApiUrl, @@ -47,13 +46,15 @@ import { import { commonErrorFallback, commonErrorHandler } from '@northern.tech/store/store'; import { getDeviceMonitorConfig, getLatestDeviceAlerts, getSingleDeployment, saveGlobalSettings } from '@northern.tech/store/thunks'; import { + combineSortCriteria, convertDeviceListStateToFilters, extractErrorMessage, filtersFilter, mapDeviceAttributes, mapFiltersToTerms, mapTermsToFilters, - progress + progress, + sortCriteriaToSortOptions } from '@northern.tech/store/utils'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { isCancel } from 'axios'; @@ -518,7 +519,7 @@ export const setDeviceListState = createAsyncThunk( setOnly: false, refreshTrigger, ...selectionState, - sort: { ...currentState.sort, ...selectionState.sort } + sort: combineSortCriteria(currentState.sort, selectionState.sort) }; let tasks = []; // eslint-disable-next-line no-unused-vars @@ -526,12 +527,10 @@ export const setDeviceListState = createAsyncThunk( // eslint-disable-next-line no-unused-vars const { isLoading: nextLoading, deviceIds: nextDevices, selection: nextSelection, ...nextRequestState } = nextState; if (!nextState.setOnly && !deepCompare(currentRequestState, nextRequestState)) { - const { direction: sortDown = SORTING_OPTIONS.desc, key: sortCol, scope: sortScope } = nextState.sort ?? {}; - const sortBy = sortCol ? [{ attribute: sortCol, order: sortDown, scope: sortScope }] : undefined; const applicableSelectedState = nextState.state === routes.allDevices.key ? undefined : nextState.state; nextState.isLoading = true; tasks.push( - dispatch(getDevicesByStatus({ ...nextState, status: applicableSelectedState, sortOptions: sortBy, fetchAuth })) + dispatch(getDevicesByStatus({ ...nextState, status: applicableSelectedState, sortOptions: sortCriteriaToSortOptions(nextState.sort), fetchAuth })) .unwrap() .then(results => { const { deviceAccu, total } = results[results.length - 1]; diff --git a/frontend/src/js/store/usersSlice/thunks.test.ts b/frontend/src/js/store/usersSlice/thunks.test.ts index eab99acd..d0560980 100644 --- a/frontend/src/js/store/usersSlice/thunks.test.ts +++ b/frontend/src/js/store/usersSlice/thunks.test.ts @@ -72,7 +72,7 @@ const settings = { test: true }; // eslint-disable-next-line no-unused-vars const { attributes, ...expectedDevice } = defaultState.devices.byId.a1; -export const offlineThreshold = [ +const offlineThreshold = [ { type: setOfflineThreshold.pending.type }, { type: appActions.setOfflineThreshold.type, payload: '2019-01-12T13:00:00.900Z' }, { type: setOfflineThreshold.fulfilled.type } diff --git a/frontend/src/js/store/utils.ts b/frontend/src/js/store/utils.ts index 50032b99..7f8783a8 100644 --- a/frontend/src/js/store/utils.ts +++ b/frontend/src/js/store/utils.ts @@ -13,7 +13,14 @@ // limitations under the License. // @ts-nocheck import { duplicateFilter, yes } from '../helpers'; -import { ATTRIBUTE_SCOPES, DEVICE_FILTERING_OPTIONS, DEVICE_ISSUE_OPTIONS, DEVICE_LIST_MAXIMUM_LENGTH, emptyUiPermissions } from './commonConstants'; +import { + ATTRIBUTE_SCOPES, + DEVICE_FILTERING_OPTIONS, + DEVICE_ISSUE_OPTIONS, + DEVICE_LIST_MAXIMUM_LENGTH, + SORTING_OPTIONS, + emptyUiPermissions +} from './commonConstants'; import { DARK_MODE, DEPLOYMENT_STATES, @@ -246,3 +253,28 @@ export const mapDeviceAttributes = (attributes = []) => ); export const isDarkMode = mode => mode === DARK_MODE; + +export const combineSortCriteria = (currentCriteria = [], newCriteria = []) => + newCriteria.reduce( + (accu, sort) => { + const existingSortIndex = accu.findIndex(({ scope, key }) => scope === sort.scope && key === sort.key); + if (existingSortIndex > -1) { + accu.splice(existingSortIndex, 1); + } + if (sort.disabled) { + return accu; + } + accu.push(sort); + return accu; + }, + [...currentCriteria.filter(({ disabled }) => !disabled)] + ); + +export const sortCriteriaToSortOptions = criteria => + criteria.reduce((accu, sort) => { + const { direction: sortDown = SORTING_OPTIONS.desc, key: sortCol, scope: sortScope } = sort; + if (sortCol) { + accu.push({ attribute: sortCol, order: sortDown, scope: sortScope }); + } + return accu; + }, []); diff --git a/frontend/tests/mockData.js b/frontend/tests/mockData.js index e4010b9d..6d78141f 100644 --- a/frontend/tests/mockData.js +++ b/frontend/tests/mockData.js @@ -129,7 +129,7 @@ export const defaultState = { deviceIds: [], searchTerm: '', searchTotal: 0, - sort: {} + sort: [] }, snackbar: {}, uploadsById: {}, @@ -307,11 +307,7 @@ export const defaultState = { selectedAttributes: [], selectedIssues: [], selection: [], - sort: { - direction: SORTING_OPTIONS.desc - // key: null, - // scope: null - }, + sort: [], state: DEVICE_STATES.accepted, total: 0 }, From 926784c37a3c941f2658f14c6b70bc9f044877ba Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Thu, 27 Jun 2024 15:36:59 +0200 Subject: [PATCH 6/9] chore: aligned search view with reality of absent sorting powers Signed-off-by: Manuel Zedel --- frontend/src/js/components/search-result.js | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/frontend/src/js/components/search-result.js b/frontend/src/js/components/search-result.js index d5cbf380..777010c7 100644 --- a/frontend/src/js/components/search-result.js +++ b/frontend/src/js/components/search-result.js @@ -105,15 +105,6 @@ export const SearchResult = ({ onToggleSearchResult, open = true }) => { dispatch(setSearchState({ page })); }; - const onSortChange = attribute => { - let changedSortCol = attribute.name; - let changedSortDown = sortDown === SORTING_OPTIONS.desc ? SORTING_OPTIONS.asc : SORTING_OPTIONS.desc; - if (changedSortCol !== sortCol) { - changedSortDown = SORTING_OPTIONS.desc; - } - dispatch(setSearchState({ page: 1, sort: { direction: changedSortDown, key: changedSortCol, scope: attribute.scope } })); - }; - const onClearClick = () => { dispatch(setSearchState({ searchTerm: '' })); onToggleSearchResult(); @@ -141,10 +132,9 @@ export const SearchResult = ({ onToggleSearchResult, open = true }) => { Date: Thu, 27 Jun 2024 15:38:53 +0200 Subject: [PATCH 7/9] chore: adopted unified sort icon where easily possible Signed-off-by: Manuel Zedel --- .../auditlogs/__snapshots__/auditlogs.test.js.snap | 3 ++- .../auditlogs/__snapshots__/auditlogslist.test.js.snap | 3 ++- frontend/src/js/components/auditlogs/auditlogslist.js | 5 +++-- frontend/src/js/components/common/sorticon.js | 2 +- frontend/src/js/components/releases/releasedetails.js | 6 +++--- frontend/src/js/components/search-result.js | 2 +- 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/frontend/src/js/components/auditlogs/__snapshots__/auditlogs.test.js.snap b/frontend/src/js/components/auditlogs/__snapshots__/auditlogs.test.js.snap index 5005824c..6687c0de 100644 --- a/frontend/src/js/components/auditlogs/__snapshots__/auditlogs.test.js.snap +++ b/frontend/src/js/components/auditlogs/__snapshots__/auditlogs.test.js.snap @@ -380,9 +380,10 @@ exports[`Auditlogs Component renders correctly 1`] = ` Time
diff --git a/frontend/src/js/components/common/sorticon.js b/frontend/src/js/components/common/sorticon.js index 3f2114dd..0bcbbad6 100644 --- a/frontend/src/js/components/common/sorticon.js +++ b/frontend/src/js/components/common/sorticon.js @@ -16,7 +16,7 @@ import React, { useEffect, useRef, useState } from 'react'; // material ui import { Sort } from '@mui/icons-material'; -import { TIMEOUTS } from '../../constants/appConstants'; +import { TIMEOUTS } from '@northern.tech/store/commonConstants'; const SortIcon = ({ columnKey, disabled = false, sortDown = false }) => { const timer = useRef(); diff --git a/frontend/src/js/components/releases/releasedetails.js b/frontend/src/js/components/releases/releasedetails.js index d9e79a4f..9e87bd07 100644 --- a/frontend/src/js/components/releases/releasedetails.js +++ b/frontend/src/js/components/releases/releasedetails.js @@ -22,8 +22,7 @@ import { HighlightOffOutlined as HighlightOffOutlinedIcon, LabelOutlined as LabelOutlinedIcon, Link as LinkIcon, - Replay as ReplayIcon, - Sort as SortIcon + Replay as ReplayIcon } from '@mui/icons-material'; import { Button, @@ -56,6 +55,7 @@ import useWindowSize from '../../utils/resizehook'; import ChipSelect from '../common/chipselect'; import { ConfirmationButtons, EditButton } from '../common/confirm'; import { EditableLongText } from '../common/editablelongtext'; +import SortIcon from '../common/sorticon'; import { RelativeTime } from '../common/time'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../helptips/helptooltips'; import Artifact from './artifact'; @@ -282,7 +282,7 @@ const ArtifactsList = ({ artifacts, selectedArtifact, setSelectedArtifact, setSh <>{item.title} - {item.sortable ? : null} + {item.sortable && } {item.tooltip}
))} diff --git a/frontend/src/js/components/search-result.js b/frontend/src/js/components/search-result.js index 777010c7..d9028802 100644 --- a/frontend/src/js/components/search-result.js +++ b/frontend/src/js/components/search-result.js @@ -20,7 +20,7 @@ import { Close as CloseIcon } from '@mui/icons-material'; import { ClickAwayListener, Drawer, IconButton, Typography } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { SORTING_OPTIONS, TIMEOUTS } from '@northern.tech/store/constants'; +import { TIMEOUTS } from '@northern.tech/store/constants'; import { getIdAttribute, getMappedDevicesList, getUserSettings } from '@northern.tech/store/selectors'; import { setDeviceListState, setSearchState } from '@northern.tech/store/thunks'; import pluralize from 'pluralize'; From 5db99577f62d73076507712c0f8f14491c7a21e3 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Thu, 27 Jun 2024 15:41:42 +0200 Subject: [PATCH 8/9] feat: added multi sorting capabilities to devices view + made alternative data table work with multi sorting data to help work with sorting info from url state Ticket: MEN-7169 Changelog: Title Signed-off-by: Manuel Zedel --- .../src/js/components/common/detailstable.js | 57 +++++++++++++++---- .../__snapshots__/device-groups.test.js.snap | 20 +++---- .../__snapshots__/devicelist.test.js.snap | 8 +-- .../components/devices/authorized-devices.js | 17 +----- .../src/js/components/devices/devicelist.js | 56 ++++++++++++------ .../js/components/devices/devicelist.test.js | 2 +- .../__snapshots__/releases.test.js.snap | 9 ++- .../__snapshots__/releaseslist.test.js.snap | 11 ++-- .../js/components/releases/releaseslist.js | 16 ++---- .../components/releases/releaseslist.test.js | 10 +++- frontend/src/js/store/appSlice/constants.ts | 3 + frontend/src/js/store/releasesSlice/thunks.ts | 10 +++- frontend/src/less/main.less | 14 +++-- 13 files changed, 149 insertions(+), 84 deletions(-) diff --git a/frontend/src/js/components/common/detailstable.js b/frontend/src/js/components/common/detailstable.js index 40557675..8ac5cdf9 100644 --- a/frontend/src/js/components/common/detailstable.js +++ b/frontend/src/js/components/common/detailstable.js @@ -11,14 +11,16 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import React from 'react'; +import React, { useEffect, useState } from 'react'; // material ui -import { Sort as SortIcon } from '@mui/icons-material'; import { Checkbox, Table, TableBody, TableCell, TableHead, TableRow } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { SORTING_OPTIONS } from '@northern.tech/store/constants'; +import { SORTING_OPTIONS, SORT_DIRECTIONS, TIMEOUTS } from '@northern.tech/store/constants'; + +import { useDebounce } from '../../utils/debouncehook'; +import SortIcon from './sorticon'; const useStyles = makeStyles()(() => ({ header: { @@ -32,16 +34,50 @@ const useStyles = makeStyles()(() => ({ } })); +const HeaderItem = ({ columnKey, hasMultiSort, extras, renderTitle, sortable, onSort, sortOptions, title }) => { + const { direction, key: sortKey } = sortOptions.find(({ key: sortKey }) => columnKey === sortKey) ?? {}; + const [sortState, setSortState] = useState({ disabled: !sortKey, direction }); + const [resetDirection] = useState(hasMultiSort ? '' : SORT_DIRECTIONS[0]); + + const debouncedSortState = useDebounce(sortState, TIMEOUTS.debounceShort); + + useEffect(() => { + if (!onSort) { + return; + } + onSort({ key: columnKey, direction: debouncedSortState.direction, disabled: debouncedSortState.disabled }); + }, [columnKey, debouncedSortState.direction, debouncedSortState.disabled, onSort]); + + const onSortClick = () => { + if (!sortable) { + return; + } + const nextDirectionIndex = SORT_DIRECTIONS.indexOf(sortState.direction) + 1; + const direction = SORT_DIRECTIONS[nextDirectionIndex] ?? resetDirection; + setSortState({ direction, disabled: !direction }); + }; + + const sortDown = sortKey && direction === SORTING_OPTIONS.desc; + + return ( + + {renderTitle ? renderTitle(extras) : title} + {sortable && } + + ); +}; + export const DetailsTable = ({ className = '', columns, + hasMultiSort = false, items, onChangeSorting, onItemClick, - sort = {}, + sort = [], style = {}, tableRef, - onRowSelected = undefined, + onRowSelected, selectedRows = [] }) => { const { classes } = useStyles(); @@ -69,23 +105,20 @@ export const DetailsTable = ({ - {onRowSelected !== undefined && ( + {!!onRowSelected && ( )} - {columns.map(({ extras, key, renderTitle, sortable, title }) => ( - (sortable ? onChangeSorting(key) : null)}> - {renderTitle ? renderTitle(extras) : title} - {sortable && } - + {columns.map(column => ( + ))} {items.map((item, index) => ( - {onRowSelected !== undefined && ( + {onRowSelected && ( onRowSelection(index)} /> diff --git a/frontend/src/js/components/devices/__snapshots__/device-groups.test.js.snap b/frontend/src/js/components/devices/__snapshots__/device-groups.test.js.snap index 300f1459..c27f09fd 100644 --- a/frontend/src/js/components/devices/__snapshots__/device-groups.test.js.snap +++ b/frontend/src/js/components/devices/__snapshots__/device-groups.test.js.snap @@ -381,7 +381,7 @@ exports[`DeviceGroups Component renders correctly 1`] = `