611947e031
## 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
137 lines
4.2 KiB
TypeScript
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 };
|
|
};
|