diff --git a/packages/manager/.changeset/pr-13547-fixed-1774943336443.md b/packages/manager/.changeset/pr-13547-fixed-1774943336443.md new file mode 100644 index 00000000000..8e10d68ebcf --- /dev/null +++ b/packages/manager/.changeset/pr-13547-fixed-1774943336443.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Increase profile preferences reliability ([#13547](https://github.com/linode/manager/pull/13547)) diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 53566087f9f..5c791a53338 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -94,6 +94,20 @@ export interface PrimaryNavProps { isCollapsed: boolean; } +/** Indices for product-family accordions when the user has not saved `collapsedSideNavProductFamilies` yet. */ +const DEFAULT_COLLAPSED_SIDE_NAV_INDICES = [1, 2, 3, 4, 5, 6, 7]; + +function getActiveSideNavGroupIndex( + groups: ProductFamilyLinkGroup[], + pathname: string +) { + return groups.findIndex((group) => { + const filteredLinks = group.links.filter((link) => !link.hide); + + return filteredLinks.some((link) => linkIsActive(pathname, link.to)); + }); +} + export const PrimaryNav = (props: PrimaryNavProps) => { const { closeMenu, desktopMenuToggle, isCollapsed } = props; const navItemsRef = React.useRef(null); @@ -131,20 +145,11 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isReserveIpEnabled } = useIsReserveIpEnabled(); - const { - data: preferences, - error: preferencesError, - isLoading: preferencesLoading, - } = usePreferences(); + const { data: preferences } = usePreferences(); const collapsedSideNavPreference = preferences?.collapsedSideNavProductFamilies; - const collapsedAccordions = React.useMemo( - () => collapsedSideNavPreference ?? [1, 2, 3, 4, 5, 6, 7], // by default, we collapse all categories if no preference is set; - [collapsedSideNavPreference] - ); - const { mutateAsync: updatePreferences } = useMutatePreferences(); const productFamilyLinkGroups: ProductFamilyLinkGroup[] = @@ -371,6 +376,30 @@ export const PrimaryNav = (props: PrimaryNavProps) => { ] ); + const collapsedAccordions = React.useMemo(() => { + if (preferences === undefined) { + return DEFAULT_COLLAPSED_SIDE_NAV_INDICES; + } + if (collapsedSideNavPreference !== undefined) { + return collapsedSideNavPreference; + } + const activeGroupIndex = getActiveSideNavGroupIndex( + productFamilyLinkGroups, + location.pathname + ); + if (activeGroupIndex === -1) { + return DEFAULT_COLLAPSED_SIDE_NAV_INDICES; + } + return DEFAULT_COLLAPSED_SIDE_NAV_INDICES.filter( + (idx) => idx !== activeGroupIndex + ); + }, [ + collapsedSideNavPreference, + preferences, + productFamilyLinkGroups, + location.pathname, + ]); + const accordionClicked = React.useCallback( (index: number) => { let updatedCollapsedAccordions: number[]; @@ -434,48 +463,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { }; }, [checkOverflow]); - // This effect will only run if the collapsedSideNavPreference is not set - // When a user lands on a page and does not have any preference set, - // we want to expand the accordion that contains the active link for convenience and discoverability - React.useEffect(() => { - // Wait for preferences to load or if there's an error - if (preferencesLoading || preferencesError) { - return; - } - - // Wait for preferences data to be available (not just the field, but the whole object) - if (!preferences) { - return; - } - - // If user has already set collapsedSideNavProductFamilies preference, don't override it - if (collapsedSideNavPreference) { - return; - } - - // Find the index of the group containing the active link and expand it - const activeGroupIndex = productFamilyLinkGroups.findIndex((group) => { - const filteredLinks = group.links.filter((link) => !link.hide); - - return filteredLinks.some((link) => - linkIsActive(location.pathname, link.to) - ); - }); - - if (activeGroupIndex !== -1) { - accordionClicked(activeGroupIndex); - } - }, [ - accordionClicked, - location.pathname, - location.search, - productFamilyLinkGroups, - collapsedSideNavPreference, - preferences, - preferencesLoading, - preferencesError, - ]); - let activeProductFamily = ''; return ( diff --git a/packages/queries/src/profile/preferences.ts b/packages/queries/src/profile/preferences.ts index cfcc9529f02..1f1f58baac8 100644 --- a/packages/queries/src/profile/preferences.ts +++ b/packages/queries/src/profile/preferences.ts @@ -1,5 +1,10 @@ import { updateUserPreferences } from '@linode/api-v4'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { queryPresets } from '../base'; import { profileQueries } from './profile'; @@ -8,6 +13,14 @@ import type { APIError } from '@linode/api-v4'; import type { ManagerPreferences } from '@linode/utilities'; import type { QueryClient } from '@tanstack/react-query'; +const isPreferencesMergeBase = ( + value: ManagerPreferences | undefined, +): value is ManagerPreferences => + value !== null && + value !== undefined && + typeof value === 'object' && + !Array.isArray(value); + // Reference for this pattern: https://tkdodo.eu/blog/react-query-data-transformations#3-using-the-select-option export const usePreferences = ( select?: (data: ManagerPreferences | undefined) => TData, @@ -32,12 +45,17 @@ export const useMutatePreferences = (replace = false) => { if (replace) { return updateUserPreferences(data); } - const existingPreferences = - await queryClient.ensureQueryData( - profileQueries.preferences, - ); + const preferencesQueryOptions = queryOptions(profileQueries.preferences); + const existingPreferences = await queryClient.ensureQueryData( + preferencesQueryOptions, + ); + return updateUserPreferences({ ...existingPreferences, ...data }); }, + onError: () => + queryClient.invalidateQueries({ + queryKey: profileQueries.preferences.queryKey, + }), onMutate: (data) => updatePreferenceData(data, replace, queryClient), }); }; @@ -49,9 +67,14 @@ export const updatePreferenceData = ( ): void => { queryClient.setQueryData( profileQueries.preferences.queryKey, - (oldData) => ({ - ...(!replace ? oldData : {}), - ...newData, - }), + (oldData) => { + if (replace) { + return { ...newData }; + } + if (!isPreferencesMergeBase(oldData)) { + return oldData; + } + return { ...oldData, ...newData }; + }, ); };