From eca500e7f7eba7ac55cdd42acf0bbddd5a6d1e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Bul=C3=A1nek?= Date: Mon, 4 Nov 2024 12:08:20 +0100 Subject: [PATCH] feat(store): use Zustand to store UserProfile --- package.json | 4 +- pnpm-lock.yaml | 51 +++++++++++++++++-- src/app/api/users/types.ts | 16 +----- src/app/auth/accept-tou/actions.ts | 2 +- src/app/auth/index.ts | 5 +- src/app/auth/rsc.tsx | 11 +--- src/app/layout.tsx | 30 ++++++----- src/components/UserAvatar/UserAvatar.tsx | 5 +- src/layout/providers/AppProvider.tsx | 4 +- src/layout/shell/AppHeader.tsx | 4 +- src/layout/shell/AppShell.tsx | 29 +++++------ src/layout/shell/UserProfile.tsx | 27 +++++----- src/modules/api-keys/ApiKeysHome.tsx | 31 +++++------ src/modules/api-keys/manage/ApiKeyModal.tsx | 18 +++---- .../assistants/builder/AssistantBaseInfo.tsx | 6 +-- src/modules/chat/EmptyChatView.tsx | 4 +- src/modules/chat/message/Message.tsx | 4 +- src/modules/projects/ProjectSelector.tsx | 8 +-- src/modules/projects/hooks/useProjects.ts | 10 ++-- src/modules/projects/users/AddUserForm.tsx | 30 +++++------ src/modules/projects/users/ProjectUserRow.tsx | 20 ++++---- .../StoreProvider.tsx} | 34 ++++++------- src/store/index.ts | 44 ++++++++++++++++ src/store/types.ts | 34 +++++++++++++ src/store/user-profile/index.ts | 42 +++++++++++++++ src/store/user-profile/types.ts | 35 +++++++++++++ 26 files changed, 343 insertions(+), 165 deletions(-) rename src/{modules/chat/providers/UserProfileProvider.tsx => store/StoreProvider.tsx} (51%) create mode 100644 src/store/index.ts create mode 100644 src/store/types.ts create mode 100644 src/store/user-profile/index.ts create mode 100644 src/store/user-profile/types.ts diff --git a/package.json b/package.json index a10f38e4..e11cd5db 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@carbon/motion": "^11.17.0", "@carbon/react": "^1.64.0", "@carbon/styles": "^1.63.0", + "@dhmk/zustand-lens": "^5.0.0", "@floating-ui/react": "^0.26.22", "@hookform/resolvers": "^3.9.0", "@opentelemetry/api-logs": "^0.53.0", @@ -81,7 +82,8 @@ "use-resize-observer": "^9.1.0", "usehooks-ts": "^3.1.0", "uuid": "^9.0.1", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zustand": "^5.0.1" }, "devDependencies": { "@commitlint/cli": "^19.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da21ed42..926dc3ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: '@carbon/styles': specifier: ^1.63.0 version: 1.68.0(sass@1.80.5) + '@dhmk/zustand-lens': + specifier: ^5.0.0 + version: 5.0.0(zustand@5.0.1(@types/react@18.3.12)(immer@10.1.1)(react@18.3.1)) '@floating-ui/react': specifier: ^0.26.22 version: 0.26.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -199,6 +202,9 @@ importers: zod: specifier: ^3.22.4 version: 3.23.8 + zustand: + specifier: ^5.0.1 + version: 5.0.1(@types/react@18.3.12)(immer@10.1.1)(react@18.3.1) devDependencies: '@commitlint/cli': specifier: ^19.2.1 @@ -1013,6 +1019,14 @@ packages: peerDependencies: postcss-selector-parser: ^6.1.0 + '@dhmk/utils@4.4.1': + resolution: {integrity: sha512-5l9xBJvaIQqbLv6vOhhArL6A/UXdATqFUdrQ7MkD3kMwGoEv1cxrSsNUIzRRjW5G1c7Kng6gu0KZf5D6tFbP9g==} + + '@dhmk/zustand-lens@5.0.0': + resolution: {integrity: sha512-PekoVd6mMpld4kdKKtFrYnVc0j9YqK41zYE35szLfzt/2XGUSUCMYDrKTSGpbExexGLMKB9FBIjvlK399UGoHg==} + peerDependencies: + zustand: ^5.0.0 + '@dual-bundle/import-meta-resolve@4.1.0': resolution: {integrity: sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==} @@ -6332,6 +6346,24 @@ packages: zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + zustand@5.0.1: + resolution: {integrity: sha512-pRET7Lao2z+n5R/HduXMio35TncTlSW68WsYBq2Lg1ASspsNGjpwLAsij3RpouyV6+kHMwwwzP0bZPD70/Jx/w==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -7324,6 +7356,13 @@ snapshots: dependencies: postcss-selector-parser: 6.1.2 + '@dhmk/utils@4.4.1': {} + + '@dhmk/zustand-lens@5.0.0(zustand@5.0.1(@types/react@18.3.12)(immer@10.1.1)(react@18.3.1))': + dependencies: + '@dhmk/utils': 4.4.1 + zustand: 5.0.1(@types/react@18.3.12)(immer@10.1.1)(react@18.3.1) + '@dual-bundle/import-meta-resolve@4.1.0': {} '@eslint-community/eslint-utils@4.4.1(eslint@8.57.1)': @@ -10428,7 +10467,7 @@ snapshots: eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.2(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -10463,7 +10502,7 @@ snapshots: is-bun-module: 1.2.1 is-glob: 4.0.3 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-node @@ -10481,7 +10520,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -13409,4 +13448,10 @@ snapshots: zod@3.23.8: {} + zustand@5.0.1(@types/react@18.3.12)(immer@10.1.1)(react@18.3.1): + optionalDependencies: + '@types/react': 18.3.12 + immer: 10.1.1 + react: 18.3.1 + zwitch@2.0.4: {} diff --git a/src/app/api/users/types.ts b/src/app/api/users/types.ts index 680de1d2..20e2e40a 100644 --- a/src/app/api/users/types.ts +++ b/src/app/api/users/types.ts @@ -14,24 +14,10 @@ * limitations under the License. */ +import { UserMetadata } from '@/store/user-profile/types'; import { paths } from '../schema'; import { EntityWithDecodedMetadata } from '../types'; -export type UserMetadata = { - email?: string; - tou_accepted_at?: number; -}; - -export interface UserProfile { - id: string; - name: string; - firstName: string; - lastName: string; - email: string; - metadata?: UserMetadata; - isDummy?: boolean; -} - export type UserResult = paths['/v1/users']['get']['responses']['200']['content']['application/json']; diff --git a/src/app/auth/accept-tou/actions.ts b/src/app/auth/accept-tou/actions.ts index c6fb813a..89ea22fa 100644 --- a/src/app/auth/accept-tou/actions.ts +++ b/src/app/auth/accept-tou/actions.ts @@ -17,8 +17,8 @@ 'use server'; import { updateUser } from '@/app/api/rsc'; -import { UserMetadata } from '@/app/api/users/types'; import { encodeMetadata } from '@/app/api/utils'; +import { UserMetadata } from '@/store/user-profile/types'; import { updateSession } from '..'; import { ensureSession } from '../rsc'; diff --git a/src/app/auth/index.ts b/src/app/auth/index.ts index 065faf8d..250086b2 100644 --- a/src/app/auth/index.ts +++ b/src/app/auth/index.ts @@ -14,13 +14,14 @@ * limitations under the License. */ +import { UserMetadata, UserProfileState } from '@/store/user-profile/types'; import { checkErrorCode } from '@/utils/handleApiError'; import NextAuth from 'next-auth'; import type { OAuthConfig } from 'next-auth/providers'; import * as z from 'zod'; import { ApiError, HttpError } from '../api/errors'; import { createUser, readUser } from '../api/users'; -import { UserEntity, UserMetadata, UserProfile } from '../api/users/types'; +import { UserEntity } from '../api/users/types'; import { decodeEntityWithMetadata, encodeMetadata, @@ -275,7 +276,7 @@ const refreshAccessTokenSchema = z.object({ declare module 'next-auth' { interface Session { - userProfile: UserProfile; + userProfile: UserProfileState; } interface User { firstName?: string; diff --git a/src/app/auth/rsc.tsx b/src/app/auth/rsc.tsx index 137161a4..d3f549a3 100644 --- a/src/app/auth/rsc.tsx +++ b/src/app/auth/rsc.tsx @@ -15,6 +15,7 @@ */ import 'server-only'; +import { defaultUserProfileState } from '@/store/user-profile'; import { JWT } from 'next-auth/jwt'; import { redirect } from 'next/navigation'; import { cache } from 'react'; @@ -32,15 +33,7 @@ export const ensureSession = async () => { user: { access_token: DUMMY_JWT_TOKEN, }, - userProfile: { - id: '', - name: 'Test User', - firstName: 'Test', - lastName: 'User', - email: 'test@email.com', - metadata: {}, - isDummy: true, - }, + userProfile: defaultUserProfileState, }; const session = await getSession(); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d093aae2..57030ee5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -18,9 +18,11 @@ import { NavigationControlProvider } from '@/layout/providers/NavigationControlP import { ProgressBarProvider } from '@/layout/providers/ProgressBarProvider'; import { ThemeProvider } from '@/layout/providers/ThemeProvider'; import { ToastProvider } from '@/layout/providers/ToastProvider'; +import { StoreProvider } from '@/store/StoreProvider'; import type { Metadata } from 'next'; import { PropsWithChildren, ReactNode } from 'react'; import { IncludeGlobalStyles } from './IncludeGlobalStyles'; +import { ensureSession } from './auth/rsc'; const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME!; @@ -29,10 +31,12 @@ export const metadata: Metadata = { icons: { icon: '//www.ibm.com/favicon.ico' }, }; -export default function RootLayout({ +export default async function RootLayout({ children, modal, }: PropsWithChildren<{ modal: ReactNode }>) { + const session = await ensureSession(); + return ( // suppressHydrationWarning is added because of ThemeProvider @@ -43,18 +47,20 @@ export default function RootLayout({ - - - - - + + + + + + - {children} - {modal} - - - - + {children} + {modal} + + + + + ); diff --git a/src/components/UserAvatar/UserAvatar.tsx b/src/components/UserAvatar/UserAvatar.tsx index 82b0f929..7e34a9d3 100644 --- a/src/components/UserAvatar/UserAvatar.tsx +++ b/src/components/UserAvatar/UserAvatar.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useUserProfile } from '@/modules/chat/providers/UserProfileProvider'; +import { useUserProfile } from '@/store/user-profile'; import clsx from 'clsx'; import { HTMLAttributes } from 'react'; import classes from './UserAvatar.module.scss'; @@ -39,6 +39,7 @@ const getUserInitials = (name: string) => { }; export function CurrentUserAvatar(props: HTMLAttributes) { - const { name } = useUserProfile(); + const name = useUserProfile((state) => state.name); + return ; } diff --git a/src/layout/providers/AppProvider.tsx b/src/layout/providers/AppProvider.tsx index c1e1631b..8bc4f3d6 100644 --- a/src/layout/providers/AppProvider.tsx +++ b/src/layout/providers/AppProvider.tsx @@ -20,9 +20,9 @@ import { Project } from '@/app/api/projects/types'; import { encodeEntityWithMetadata } from '@/app/api/utils'; import { readAssistantQuery } from '@/modules/assistants/queries'; import { Assistant } from '@/modules/assistants/types'; -import { useUserProfile } from '@/modules/chat/providers/UserProfileProvider'; import { readProjectQuery } from '@/modules/projects/queries'; import { readProjectUserQuery } from '@/modules/projects/users/queries'; +import { useUserProfile } from '@/store/user-profile'; import { useQuery } from '@tanstack/react-query'; import { createContext, @@ -67,7 +67,7 @@ export function AppProvider({ const [project, setProject] = useState(initialProject); const [assistant, setAssistant] = useState(null); const onPageLeaveRef = useRef(() => null); - const { id } = useUserProfile(); + const id = useUserProfile((state) => state.id); const { data: projectData } = useQuery({ ...readProjectQuery(project.id), diff --git a/src/layout/shell/AppHeader.tsx b/src/layout/shell/AppHeader.tsx index d291bb5c..7a37caf1 100644 --- a/src/layout/shell/AppHeader.tsx +++ b/src/layout/shell/AppHeader.tsx @@ -17,10 +17,12 @@ 'use client'; import { prefetchThreads } from '@/modules/chat/history/queries'; import { ThreadsHistory } from '@/modules/chat/history/ThreadsHistory'; +import { ProjectSelector } from '@/modules/projects/ProjectSelector'; import { useQueryClient } from '@tanstack/react-query'; import clsx from 'clsx'; import { useEffect, useId, useRef, useState } from 'react'; import { useUserSetting } from '../hooks/useUserSetting'; +import { useAppContext } from '../providers/AppProvider'; import { ActionButton } from './ActionButton'; import classes from './AppHeader.module.scss'; import { CollapsibleGroup } from './CollapsibleGroup'; @@ -29,8 +31,6 @@ import Pinned from './Pinned.svg'; import { RecentAssistantsList } from './RecentAssistantsList'; import Unpinned from './Unpinned.svg'; import { UserNav } from './UserNav'; -import { useAppContext } from '../providers/AppProvider'; -import { ProjectSelector } from '@/modules/projects/ProjectSelector'; export function AppHeader() { const id = useId(); diff --git a/src/layout/shell/AppShell.tsx b/src/layout/shell/AppShell.tsx index 139011c6..d8db3641 100644 --- a/src/layout/shell/AppShell.tsx +++ b/src/layout/shell/AppShell.tsx @@ -14,16 +14,14 @@ * limitations under the License. */ -import { ensureSession } from '@/app/auth/rsc'; +import { readProject } from '@/app/api/rsc'; import { ErrorPage } from '@/components/ErrorPage/ErrorPage'; -import { UserProfileProvider } from '@/modules/chat/providers/UserProfileProvider'; +import { handleApiError } from '@/utils/handleApiError'; +import { notFound } from 'next/navigation'; import { PropsWithChildren } from 'react'; import { AppProvider } from '../providers/AppProvider'; import { AppHeader } from './AppHeader'; import classes from './AppShell.module.scss'; -import { handleApiError } from '@/utils/handleApiError'; -import { notFound } from 'next/navigation'; -import { readProject } from '@/app/api/rsc'; interface Props { projectId: string; @@ -33,9 +31,8 @@ export async function AppShell({ projectId, children, }: PropsWithChildren) { - const session = await ensureSession(); - let project; + try { project = await readProject(projectId); } catch (e) { @@ -54,16 +51,14 @@ export async function AppShell({ if (!project) notFound(); return ( - - -
- + +
+ -
- {children} -
-
-
- +
+ {children} +
+
+
); } diff --git a/src/layout/shell/UserProfile.tsx b/src/layout/shell/UserProfile.tsx index d8d44300..aa8b9483 100644 --- a/src/layout/shell/UserProfile.tsx +++ b/src/layout/shell/UserProfile.tsx @@ -17,24 +17,21 @@ 'use client'; import { ExternalLink } from '@/components/ExternalLink/ExternalLink'; -import { - CurrentUserAvatar, - UserAvatar, -} from '@/components/UserAvatar/UserAvatar'; +import { Link } from '@/components/Link/Link'; +import { CurrentUserAvatar } from '@/components/UserAvatar/UserAvatar'; import { useModal } from '@/layout/providers/ModalProvider'; -import { useUserProfile } from '@/modules/chat/providers/UserProfileProvider'; +import { useUserProfile } from '@/store/user-profile'; +import { PRIVACY_URL, TOU_TEXT } from '@/utils/constants'; +import { isNotNull } from '@/utils/helpers'; import { Button, Popover, PopoverContent } from '@carbon/react'; +import { Settings } from '@carbon/react/icons'; import { CODE_ESCAPE } from 'keycode-js'; import { signOut } from 'next-auth/react'; import { KeyboardEventHandler, useId, useMemo, useRef, useState } from 'react'; import { useOnClickOutside } from 'usehooks-ts'; +import { useAppContext } from '../providers/AppProvider'; import { TermsOfUseModal } from './TermsOfUseModal'; import classes from './UserProfile.module.scss'; -import { Link } from '@/components/Link/Link'; -import { isNotNull } from '@/utils/helpers'; -import { PRIVACY_URL, TOU_TEXT } from '@/utils/constants'; -import { Settings } from '@carbon/react/icons'; -import { useAppContext } from '../providers/AppProvider'; export function UserProfile() { const [open, setOpen] = useState(false); @@ -44,7 +41,11 @@ export function UserProfile() { const id = useId(); const { openModal } = useModal(); - const { name, email, isDummy } = useUserProfile(); + const userId = useUserProfile((state) => state.id); + const name = useUserProfile((state) => state.name); + const email = useUserProfile((state) => state.email); + + const isDummyUser = userId === ''; useOnClickOutside(ref, () => { setOpen(false); @@ -102,7 +103,7 @@ export function UserProfile() { aria-expanded={open} aria-controls={id} > - {isDummy ? ( + {isDummyUser ? ( @@ -151,7 +152,7 @@ export function UserProfile() { - {!isDummy && ( + {!isDummyUser && (