Files
twenty/packages/twenty-front/src/modules/navigation-menu-item/common/hooks/useCreateNavigationMenuItem.ts
T
Charles Bochet 611947e031 fix: metadata store lifecycle during sign-in, sign-out, and locale change (#18901)
## Summary

Fixes metadata lifecycle bugs during sign-in, sign-out, and locale
change:

- **Cross-tab sign-out**: Broadcasts sign-out via `BroadcastChannel` so
other tabs clear their session gracefully instead of hitting stale-token
errors
- **SSE teardown on sign-out**: Handles `UNAUTHENTICATED`/`FORBIDDEN`
errors in the SSE event stream effect instead of throwing unhandled
errors
- **Locale switch resilience**: `invalidateAndReload` now invalidates
collection hashes instead of clearing the store to empty, so components
never see 0 metadata items during the reload transition
- **Sign-in background mock**: Uses non-throwing
`objectMetadataItemFamilySelector` instead of hooks that throw on
missing metadata
- **View name placeholders**: Guards against `undefined` `viewName`
during metadata transitions (the minimal metadata query doesn't include
`name`)
- **Session cleanup**: Selective `localStorage` clearing
(`clearSessionLocalStorageKeys`) preserves metadata keys;
`clearAllSessionLocalStorageKeys` for full clears
- **Metadata reload API**: New `useMetadataStoreActions` hook as the
high-level API for metadata lifecycle operations (`applyMockedMetadata`,
`invalidateAndReload`, `loadMockedMetadataAtomic`)

## Test plan

- [ ] Sign out on Tab A → Tab A shows sign-in page with no console
errors
- [ ] Tab B (logged in) receives cross-tab broadcast and redirects to
sign-in
- [ ] No "Forbidden resource" SSE errors in console during sign-out
- [ ] Change language in Settings > Experience → no crash, metadata
refreshes in background
- [ ] Sign back in after sign-out → metadata loads correctly, app is
functional
- [ ] Re-sign-in after locale change → correct locale is preserved
2026-03-24 14:57:53 +00:00

137 lines
4.2 KiB
TypeScript

import { useMutation } from '@apollo/client/react';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import {
type CreateNavigationMenuItemInput,
CreateNavigationMenuItemDocument,
type NavigationMenuItem,
} from '~/generated-metadata/graphql';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
const buildOptimisticNavigationMenuItem = (
input: CreateNavigationMenuItemInput & { id: string },
): NavigationMenuItem => ({
id: input.id,
type: input.type,
position: input.position ?? 0,
userWorkspaceId: input.userWorkspaceId ?? null,
targetRecordId: input.targetRecordId ?? null,
targetObjectMetadataId: input.targetObjectMetadataId ?? null,
viewId: input.viewId ?? null,
folderId: input.folderId ?? null,
name: input.name ?? null,
link: input.link ?? null,
icon: input.icon ?? null,
color: input.color ?? null,
applicationId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
export const useCreateNavigationMenuItem = () => {
const { navigationMenuItems, currentWorkspaceMemberId } =
useNavigationMenuItemsData();
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const { addToDraft, applyChanges } = useUpdateMetadataStoreDraft();
const [createNavigationMenuItemMutation] = useMutation(
CreateNavigationMenuItemDocument,
);
const createNavigationMenuItem = async (
targetRecord: ObjectRecord,
targetObjectNameSingular: string,
folderId?: string,
) => {
const isView = targetObjectNameSingular === 'view';
const id = uuidv4();
const relevantItems = folderId
? navigationMenuItems.filter((item) => item.folderId === folderId)
: navigationMenuItems.filter(
(item) =>
!isDefined(item.folderId) && isDefined(item.userWorkspaceId),
);
const maxPosition = Math.max(
...relevantItems.map((item) => item.position),
0,
);
const position = maxPosition + 1;
if (isView) {
const input: CreateNavigationMenuItemInput = {
id,
type: NavigationMenuItemType.VIEW,
viewId: targetRecord.id,
userWorkspaceId: currentWorkspaceMemberId,
folderId,
position,
};
addToDraft({
key: 'navigationMenuItems',
items: [buildOptimisticNavigationMenuItem({ ...input, id })],
});
applyChanges();
const result = await createNavigationMenuItemMutation({
variables: { input },
});
const created = result.data?.createNavigationMenuItem;
if (isDefined(created)) {
addToDraft({ key: 'navigationMenuItems', items: [created] });
applyChanges();
}
} else {
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === targetObjectNameSingular,
);
if (!isDefined(objectMetadataItem)) {
throw new Error(
`Object metadata item not found for nameSingular: ${targetObjectNameSingular}`,
);
}
const input: CreateNavigationMenuItemInput = {
id,
type: NavigationMenuItemType.RECORD,
targetRecordId: targetRecord.id,
targetObjectMetadataId: objectMetadataItem.id,
userWorkspaceId: currentWorkspaceMemberId,
folderId,
position,
};
addToDraft({
key: 'navigationMenuItems',
items: [buildOptimisticNavigationMenuItem({ ...input, id })],
});
applyChanges();
const result = await createNavigationMenuItemMutation({
variables: { input },
});
const created = result.data?.createNavigationMenuItem;
if (isDefined(created)) {
addToDraft({ key: 'navigationMenuItems', items: [created] });
applyChanges();
}
}
};
return { createNavigationMenuItem };
};