Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-13547-fixed-1774943336443.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Fixed
---

Increase profile preferences reliability ([#13547](https://github.com/linode/manager/pull/13547))
91 changes: 39 additions & 52 deletions packages/manager/src/components/PrimaryNav/PrimaryNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PrimaryLinkType[]>[],
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<HTMLDivElement>(null);
Expand Down Expand Up @@ -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<PrimaryLinkType[]>[] =
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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,
]);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this one has always been questionable and a potential culprit for the faulty reset


let activeProductFamily = '';

return (
Expand Down
57 changes: 48 additions & 9 deletions packages/queries/src/profile/preferences.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,6 +13,28 @@ import type { APIError } from '@linode/api-v4';
import type { ManagerPreferences } from '@linode/utilities';
import type { QueryClient } from '@tanstack/react-query';

/**
* `PUT /profile/preferences` replaces the entire blob. Merge mode must spread a real base object.
* This rejects `undefined` / null / arrays — not an empty object `{}`.
* An empty object is still dangerous if it *should* have contained server keys (stale cache); we
* address that by calling `getUserPreferences()` in the mutation (without writing the React Query
* cache) so optimistic UI from `onMutate` is not overwritten by a mid-flight GET.
*/
export const PREFERENCES_MERGE_FAILED: APIError[] = [
{
reason:
'Preferences could not be loaded, so your change was not saved. Refresh the page and try again.',
},
];

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 = <TData = ManagerPreferences>(
select?: (data: ManagerPreferences | undefined) => TData,
Expand All @@ -32,12 +59,19 @@ export const useMutatePreferences = (replace = false) => {
if (replace) {
return updateUserPreferences(data);
}
const existingPreferences =
await queryClient.ensureQueryData<ManagerPreferences>(
profileQueries.preferences,
);
const preferencesQueryOptions = queryOptions(profileQueries.preferences);
const existingPreferences = await queryClient.ensureQueryData(
preferencesQueryOptions,
);
if (!isPreferencesMergeBase(existingPreferences)) {
throw PREFERENCES_MERGE_FAILED;
}
return updateUserPreferences({ ...existingPreferences, ...data });
},
onError: () =>
queryClient.invalidateQueries({
queryKey: profileQueries.preferences.queryKey,
}),
onMutate: (data) => updatePreferenceData(data, replace, queryClient),
});
};
Expand All @@ -49,9 +83,14 @@ export const updatePreferenceData = (
): void => {
queryClient.setQueryData<ManagerPreferences>(
profileQueries.preferences.queryKey,
(oldData) => ({
...(!replace ? oldData : {}),
...newData,
}),
(oldData) => {
if (replace) {
return { ...newData };
}
if (!isPreferencesMergeBase(oldData)) {
return oldData;
}
return { ...oldData, ...newData };
},
);
};
Loading