diff --git a/frontend/src/components/FilterDrawer.tsx b/frontend/src/components/FilterDrawer.tsx index cb2d08980..96d00641d 100644 --- a/frontend/src/components/FilterDrawer.tsx +++ b/frontend/src/components/FilterDrawer.tsx @@ -9,6 +9,7 @@ import { TagFilterToggle } from './TagFilterToggle' import { FilterSelector } from './FilterSelector' import { AccordionMenu } from './AccordionMenu' import { selectTags } from '../selectors/tags' +import { byName } from '../helpers/utilHelper' import { useLabel } from '../hooks/useLabel' import { Drawer } from './Drawer' @@ -135,7 +136,7 @@ export const FilterDrawer: React.FC = () => { filterList={platformFilter.concat( Object.keys(platforms.nameLookup) .map(p => ({ value: parseInt(p), name: platforms.nameLookup[p] })) - .sort((a, b) => (a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1)) + .sort(byName) )} /> ), diff --git a/frontend/src/components/OrganizationGuestList.tsx b/frontend/src/components/OrganizationGuestList.tsx index 41253917b..d1eb3b069 100644 --- a/frontend/src/components/OrganizationGuestList.tsx +++ b/frontend/src/components/OrganizationGuestList.tsx @@ -6,6 +6,7 @@ import { useGuests } from '../hooks/useGuests' import { LoadingMessage } from './LoadingMessage' import { Pagination } from '@mui/lab' import { Gutters } from './Gutters' +import { alphaSort } from '../helpers/utilHelper' import { Avatar } from './Avatar' import { Icon } from './Icon' @@ -15,7 +16,7 @@ export const OrganizationGuestList: React.FC = () => { const perPage = 20 const pageCount = Math.ceil(guests.length / perPage) const start = (page - 1) * perPage - const pageGuests = [...guests].sort(alphaEmailSort).slice(start, start + perPage) + const pageGuests = [...guests].sort((a, b) => alphaSort(a.email, b.email)).slice(start, start + perPage) if (!guestsLoaded) return @@ -70,8 +71,3 @@ export const OrganizationGuestList: React.FC = () => { ) } -function alphaEmailSort(a, b) { - const aa = a.email.toLowerCase() - const bb = b.email.toLowerCase() - return aa > bb ? 1 : aa < bb ? -1 : 0 -} diff --git a/frontend/src/components/OrganizationMemberList.tsx b/frontend/src/components/OrganizationMemberList.tsx index a240307c1..76ba7f5bc 100644 --- a/frontend/src/components/OrganizationMemberList.tsx +++ b/frontend/src/components/OrganizationMemberList.tsx @@ -2,6 +2,7 @@ import React from 'react' import { useSelector } from 'react-redux' import { selectAvailableUsers } from '../selectors/organizations' import { OrganizationMember } from '../components/OrganizationMember' +import { alphaSort } from '../helpers/utilHelper' import { IOrganizationState } from '../models/organization' import { List } from '@mui/material' @@ -9,7 +10,7 @@ type Props = { organization?: IOrganizationState; owner?: IOrganizationMember; e export const OrganizationMemberList: React.FC = ({ organization, owner, enterprise }) => { const freeUsers = useSelector(selectAvailableUsers) - const members = organization?.members ? [...organization.members].sort(alphaEmailSort) : [] + const members = organization?.members ? [...organization.members].sort((a, b) => alphaSort(a.user.email, b.user.email)) : [] return ( {owner && ( @@ -35,8 +36,3 @@ export const OrganizationMemberList: React.FC = ({ organization, owner, e ) } -function alphaEmailSort(a, b) { - const aa = a.user.email.toLowerCase() - const bb = b.user.email.toLowerCase() - return aa > bb ? 1 : aa < bb ? -1 : 0 -} diff --git a/frontend/src/components/OrganizationSelect.tsx b/frontend/src/components/OrganizationSelect.tsx index 1d86c2d83..4a4c77c49 100644 --- a/frontend/src/components/OrganizationSelect.tsx +++ b/frontend/src/components/OrganizationSelect.tsx @@ -10,6 +10,7 @@ import { selectAllConnectionSessions } from '../selectors/connections' import { selectOrganization } from '../selectors/organizations' import { GuideBubble } from './GuideBubble' import { fontSizes } from '../styling' +import { byName } from '../helpers/utilHelper' import { Avatar } from './Avatar' import { Icon } from './Icon' @@ -146,7 +147,7 @@ export const OrganizationSelect: React.FC = () => { } } - options.sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)) + options.sort(byName) if (!options.length) return null const mySessions = sessions.filter(s => s.target.accountId === ownOrg?.id).length diff --git a/frontend/src/components/OrganizationSelectList.tsx b/frontend/src/components/OrganizationSelectList.tsx index 0ce4334a7..2fff0ece6 100644 --- a/frontend/src/components/OrganizationSelectList.tsx +++ b/frontend/src/components/OrganizationSelectList.tsx @@ -7,6 +7,7 @@ import { ListItemButton, ListSubheader, ListItemIcon, ListItemText, Chip } from import { getOwnOrganization } from '../models/organization' import { selectOrganization } from '../selectors/organizations' import { IconButton } from '../buttons/IconButton' +import { byName } from '../helpers/utilHelper' import { Avatar } from './Avatar' const AVATAR_SIZE = 28 @@ -45,7 +46,7 @@ export const OrganizationSelectList: React.FC = () => { } } - options.sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)) + options.sort(byName) if (!options.length) return null return ( diff --git a/frontend/src/components/SharedUsersLists.tsx b/frontend/src/components/SharedUsersLists.tsx index 3ead0625c..d2870f729 100644 --- a/frontend/src/components/SharedUsersLists.tsx +++ b/frontend/src/components/SharedUsersLists.tsx @@ -9,6 +9,7 @@ import { selectMembersWithAccess } from '../selectors/organizations' import { selectOrganization } from '../selectors/organizations' import { ShareButton } from '../buttons/ShareButton' import { IconButton } from '../buttons/IconButton' +import { alphaSort } from '../helpers/utilHelper' import { Gutters } from './Gutters' import { Icon } from './Icon' @@ -93,4 +94,4 @@ export const SharedUsersLists: React.FC = ({ device, network, connected = ) } -const sort = (users: IUser[]) => users.sort((a, b) => (a.email > b.email ? 1 : b.email > a.email ? -1 : 0)) +const sort = (users: IUser[]) => users.sort((a, b) => alphaSort(a.email, b.email)) diff --git a/frontend/src/components/SortServices.tsx b/frontend/src/components/SortServices.tsx index 487d7304a..3e3e46612 100644 --- a/frontend/src/components/SortServices.tsx +++ b/frontend/src/components/SortServices.tsx @@ -3,6 +3,7 @@ import { selectDeviceModelAttributes } from '../selectors/devices' import { IconButton, Menu, MenuItem } from '@mui/material' import { useDispatch, useSelector } from 'react-redux' import { State, Dispatch } from '../store' +import { byName } from '../helpers/utilHelper' import { Icon } from './Icon' export function getSortOptions(key: ISortServiceType) { @@ -13,14 +14,12 @@ export function getSortOptions(key: ISortServiceType) { const optionSortServices: IOptionServiceSort = { ATOZ: { name: 'Alpha A-Z', - sortService: (a: IService, b: IService) => - a.name.toLowerCase() > b.name.toLowerCase() ? 1 : a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 0, + sortService: byName, icon: 'sort-alpha-down', }, ZTOA: { name: 'Alpha Z-A', - sortService: (a: IService, b: IService) => - a.name.toLowerCase() < b.name.toLowerCase() ? 1 : a.name.toLowerCase() > b.name.toLowerCase() ? -1 : 0, + sortService: (a: IService, b: IService) => byName(b, a), icon: 'sort-alpha-up', }, NEWEST: { diff --git a/frontend/src/components/Tags.tsx b/frontend/src/components/Tags.tsx index 3a01078e9..aca96a25b 100644 --- a/frontend/src/components/Tags.tsx +++ b/frontend/src/components/Tags.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react' import { Chip, BoxProps, Typography } from '@mui/material' +import { byName } from '../helpers/utilHelper' import { Tag } from './Tag' export type TagProps = BoxProps & { @@ -14,7 +15,7 @@ export type TagProps = BoxProps & { export const Tags: React.FC = ({ tags, small, max = 1, showEmpty, hideLabels, onClick, onDelete }) => { const dot = tags.length > max && small - const sortedTags = useMemo(() => [...tags].sort(nameSort), [tags]) + const sortedTags = useMemo(() => [...tags].sort(byName), [tags]) if (!tags.length && showEmpty) return ( @@ -37,6 +38,3 @@ export const Tags: React.FC = ({ tags, small, max = 1, showEmpty, hide return <>{dot ? : tagElements} } -function nameSort(a: ITag, b: ITag) { - return a.name.localeCompare(b.name) -} diff --git a/frontend/src/helpers/selectedHelper.ts b/frontend/src/helpers/selectedHelper.ts index 991771fe0..c185d8277 100644 --- a/frontend/src/helpers/selectedHelper.ts +++ b/frontend/src/helpers/selectedHelper.ts @@ -1,5 +1,6 @@ import structuredClone from '@ungap/structured-clone' import { getDevices } from '../selectors/devices' +import { byName } from './utilHelper' import { State } from '../store' export function eachSelectedDevice(state: State, selected: IDevice['id'][], callback: (device: IDevice) => void) { @@ -17,5 +18,5 @@ export function getSelectedTags(devices?: IDevice[], selected?: IDevice['id'][]) }) } }) - return result.sort((a, b) => a.name.localeCompare(b.name)) + return result.sort(byName) } diff --git a/frontend/src/helpers/utilHelper.ts b/frontend/src/helpers/utilHelper.ts index 6bca906b2..432f5cbbe 100644 --- a/frontend/src/helpers/utilHelper.ts +++ b/frontend/src/helpers/utilHelper.ts @@ -1,5 +1,19 @@ import { REGEX_VALID_IP } from '../constants' +// One comparator behind every alphabetical sort in the UI, so "alphabetical" means the same +// thing everywhere: numeric so device2 sorts before device10, base sensitivity so case and +// accents don't split otherwise equal names. Held as a collator because localeCompare with an +// options object builds a new one on every call. +const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) + +export function alphaSort(a: string = '', b: string = '') { + return collator.compare(a, b) +} + +export function byName(a: T, b: T) { + return alphaSort(a.name, b.name) +} + export function toLookup(array: T[], key: string): ILookup { return array.reduce((obj, item) => ({ ...obj, [item[key]]: item }), {}) } diff --git a/frontend/src/models/connections.ts b/frontend/src/models/connections.ts index 42d95053a..9f3dc1afa 100644 --- a/frontend/src/models/connections.ts +++ b/frontend/src/models/connections.ts @@ -3,7 +3,7 @@ import browser from '../services/browser' import structuredClone from '@ungap/structured-clone' import { createModel } from '@rematch/core' import { parse as urlParse } from 'url' -import { pickTruthy } from '../helpers/utilHelper' +import { alphaSort, pickTruthy } from '../helpers/utilHelper' import { DEFAULT_CONNECTION, IP_PRIVATE } from '@common/constants' import { REGEX_HIDDEN_PASSWORD, CERTIFICATE_DOMAIN } from '../constants' import { @@ -570,7 +570,7 @@ export default createModel()({ }, async setAll(all: IConnection[]) { - all.sort((a, b) => nameSort(a.name || '', b.name || '')) + all.sort((a, b) => alphaSort(a.name, b.name)) dispatch.connections.set({ all: [...all] }) // to ensure we trigger update }, }), @@ -593,6 +593,3 @@ export default createModel()({ }, }) -function nameSort(a: string, b: string) { - return a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0 -} diff --git a/frontend/src/models/search.ts b/frontend/src/models/search.ts index 61b387d93..5798b2bde 100644 --- a/frontend/src/models/search.ts +++ b/frontend/src/models/search.ts @@ -4,6 +4,7 @@ import { removeDeviceName } from '@common/nameHelper' import { graphQLBasicRequest } from '../services/graphQL' import { selectActiveAccountId } from '../selectors/accounts' import { selectDeviceModelAttributes } from '../selectors/devices' +import { alphaSort } from '../helpers/utilHelper' import { RootModel } from '.' type ISearchState = ILookup & { @@ -156,10 +157,6 @@ export default createModel()({ }) export function sortSearch(search: ISearch[]): ISearch[] { - const sorted = search.sort((a, b) => { - if (a.nodeName.toLowerCase() > b.nodeName.toLowerCase()) return 1 - if (a.nodeName.toLowerCase() < b.nodeName.toLowerCase()) return -1 - return 0 - }) + const sorted = search.sort((a, b) => alphaSort(a.nodeName, b.nodeName)) return sorted || [] } diff --git a/frontend/src/pages/NetworksPage.tsx b/frontend/src/pages/NetworksPage.tsx index 91069559f..3d50c034a 100644 --- a/frontend/src/pages/NetworksPage.tsx +++ b/frontend/src/pages/NetworksPage.tsx @@ -13,13 +13,14 @@ import { IconButton } from '../buttons/IconButton' import { Container } from '../components/Container' import { Network } from '../components/Network' import { Gutters } from '../components/Gutters' +import { byName } from '../helpers/utilHelper' import { Title } from '../components/Title' import { Icon } from '../components/Icon' export const NetworksPage: React.FC = () => { const { t } = useTranslation() const dispatch = useDispatch() - const all = [...useSelector(selectNetworks)].sort((a, b) => (a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1)) + const all = [...useSelector(selectNetworks)].sort(byName) const initialized = useSelector((state: State) => state.networks.initialized) const permissions = useSelector(selectPermissions) const loading = useSelector(selectDeviceModelAttributes).fetching diff --git a/frontend/src/selectors/contacts.ts b/frontend/src/selectors/contacts.ts index 10de470e6..47ffde2b3 100644 --- a/frontend/src/selectors/contacts.ts +++ b/frontend/src/selectors/contacts.ts @@ -1,6 +1,7 @@ import { createSelector } from 'reselect' import { State } from '../store' import { selectOrganization } from './organizations' +import { alphaSort } from '../helpers/utilHelper' import { isUserAccount } from './accounts' const getContacts = (state: State) => state.contacts.all @@ -24,6 +25,6 @@ export const selectContacts = createSelector( seen.add(key) return true }) - .sort((a, b) => a.email.localeCompare(b.email, undefined, { sensitivity: 'base' })) + .sort((a, b) => alphaSort(a.email, b.email)) } ) diff --git a/frontend/src/selectors/tags.ts b/frontend/src/selectors/tags.ts index b2d354af7..c40c3cc8c 100644 --- a/frontend/src/selectors/tags.ts +++ b/frontend/src/selectors/tags.ts @@ -1,7 +1,8 @@ import { getTags } from './state' import { createSelector } from 'reselect' import { selectActiveAccountId } from './accounts' +import { byName } from '../helpers/utilHelper' export const selectTags = createSelector([getTags, selectActiveAccountId], (tags, accountId) => - [...(tags[accountId] || [])].sort((a, b) => a.name.localeCompare(b.name)) + [...(tags[accountId] || [])].sort(byName) )