From 790e5f1dba9f1286308896b85188aba089715a2c Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 24 Apr 2026 22:22:28 +0330 Subject: [PATCH] refactor(workspaces): normalize workspace bootstrap and edit flows --- src/api/workspaces.ts | 45 ++++++---- src/components/WorkspaceSelector.tsx | 26 +++--- src/context/AppContext.tsx | 112 +++++++++-------------- src/context/WorkspaceContext.tsx | 130 +++++++++++++++------------ src/pages/WorkspaceCreate.tsx | 2 +- src/pages/WorkspaceEdit.tsx | 91 +++++++++---------- 6 files changed, 199 insertions(+), 207 deletions(-) diff --git a/src/api/workspaces.ts b/src/api/workspaces.ts index 0b3128e..cef95c9 100644 --- a/src/api/workspaces.ts +++ b/src/api/workspaces.ts @@ -1,7 +1,7 @@ import { authFetch } from "./client"; -export interface Workspace { - id: string; +export interface Workspace { + id: string; name: string; description?: string; owner?: string; @@ -16,9 +16,9 @@ export interface PaginatedResponse { results: T[]; } -export interface WorkspaceMembership { - id: string; - workspace: string; +export interface WorkspaceMembership { + id: string; + workspace: string; user: { id: string; email: string; @@ -29,14 +29,27 @@ export interface WorkspaceMembership { role: 'owner' | 'admin' | 'member' | 'guest'; is_active: boolean; joined_at?: string; - [key: string]: any; -} - - -export const fetchWorkspaces = async (params?: Record): Promise> => { - const query = params ? new URLSearchParams(params).toString() : ''; - const url = `/api/workspaces/${query ? `?${query}` : ''}`; - const response = await authFetch(url); + [key: string]: any; +} + + +type QueryValue = string | number | boolean | undefined | null; + +const toQueryString = (params?: Record) => { + if (!params) return ""; + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + query.set(key, String(value)); + } + }); + return query.toString(); +}; + +export const fetchWorkspaces = async (params?: Record): Promise> => { + const query = toQueryString(params); + const url = `/api/workspaces/${query ? `?${query}` : ''}`; + const response = await authFetch(url); if (!response.ok) { throw new Error("Failed to fetch workspaces"); @@ -98,9 +111,9 @@ export const deleteWorkspace = async (id: string): Promise => { } }; -export const fetchWorkspaceMemberships = async (params?: Record): Promise> => { - const queryParams = new URLSearchParams((params || {})); - const response = await authFetch(`/api/workspace-memberships/?${queryParams.toString()}`); +export const fetchWorkspaceMemberships = async (params?: Record): Promise> => { + const queryParams = toQueryString(params); + const response = await authFetch(`/api/workspace-memberships/?${queryParams.toString()}`); if (!response.ok) throw new Error("Failed to fetch workspace memberships"); diff --git a/src/components/WorkspaceSelector.tsx b/src/components/WorkspaceSelector.tsx index f306062..4c58b8d 100644 --- a/src/components/WorkspaceSelector.tsx +++ b/src/components/WorkspaceSelector.tsx @@ -40,22 +40,22 @@ export const WorkspaceSelector: React.FC = () => { refreshWorkspacesList(); }) as EventListener; - const handleWorkspaceCreated = ((e: CustomEvent) => { - if (e.detail) { - setActiveWorkspace(e.detail); - } - refreshWorkspacesList(); - }) as EventListener; + const handleWorkspaceCreated = ((e: CustomEvent) => { + if (e.detail?.id) { + setActiveWorkspace(e.detail); + } + refreshWorkspacesList(); + }) as EventListener; const handleWorkspaceEdited = ((e: CustomEvent) => { // آپدیت نام کارتابل در نوبار در صورتی که کارتابل فعال ویرایش شده باشد - if (activeWorkspace?.id === e.detail?.id) { - setActiveWorkspace({ - ...activeWorkspace, - name: e.detail.name, - description: e.detail.description - }); - } + if (activeWorkspace?.id === e.detail?.id) { + setActiveWorkspace({ + ...activeWorkspace, + name: e.detail.name, + description: e.detail.description + } as Workspace); + } refreshWorkspacesList(); }) as EventListener; diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index ab0b0a8..88805cc 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -1,71 +1,47 @@ -import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; -import { getUserProfile } from '../api/users'; -import { fetchWorkspaces } from '../api/workspaces'; - -interface User { - id: string; - phone_number: string; - first_name: string; - last_name: string; -} - -interface Workspace { - id: string; - name: string; -} - -interface AppContextType { - user: User | null; - workspaces: Workspace[]; - activeWorkspace: Workspace | null; - setActiveWorkspace: (ws: Workspace) => void; - fetchInitialData: () => Promise; -} - -const AppContext = createContext(null); - -export const AppProvider = ({ children }: { children: ReactNode }) => { - const [user, setUser] = useState(null); - const [workspaces, setWorkspaces] = useState([]); - const [activeWorkspace, setActiveWorkspace] = useState(null); - - const fetchInitialData = async () => { - try { - const [userData, wsData] = await Promise.all([ - getUserProfile(), - fetchWorkspaces() // fetchWorkspaces({ limit: 50 }) - ]); - - setUser(userData); - - const workspacesList = Array.isArray(wsData.data) ? wsData.data : (wsData?.data?.results || []); - - setWorkspaces(workspacesList); - - const savedWsId = localStorage.getItem('active_workspace'); - const targetWs = workspacesList.find((w: Workspace) => w.id === savedWsId) || workspacesList[0]; - - if (targetWs) { - setActiveWorkspace(targetWs); - localStorage.setItem('active_workspace', targetWs.id); - } - } catch (error) { - console.error("Failed to fetch initial context data:", error); - } - }; - - useEffect(() => { - if (localStorage.getItem('accessToken')) { - fetchInitialData(); - } - }, []); - - return ( - - {children} - - ); -}; +import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; +import { getUserProfile } from '../api/users'; + +interface User { + id: string; + mobile: string; + first_name: string; + last_name: string; + email?: string; + profile_picture?: string | null; +} + +interface AppContextType { + user: User | null; + refreshUser: () => Promise; +} + +const AppContext = createContext(null); + +export const AppProvider = ({ children }: { children: ReactNode }) => { + const [user, setUser] = useState(null); + + const refreshUser = async () => { + try { + const userData = await getUserProfile(); + setUser(userData); + } catch (error) { + console.error("Failed to fetch user context data:", error); + setUser(null); + } + }; + + useEffect(() => { + if (localStorage.getItem('accessToken')) { + void refreshUser(); + } + }, []); + + return ( + + {children} + + ); +}; export const useAppContext = () => { const context = useContext(AppContext); diff --git a/src/context/WorkspaceContext.tsx b/src/context/WorkspaceContext.tsx index 898cdb3..b1c8b74 100644 --- a/src/context/WorkspaceContext.tsx +++ b/src/context/WorkspaceContext.tsx @@ -1,17 +1,18 @@ -import { createContext, useContext, useState, useEffect, ReactNode } from "react" +import { createContext, useContext, useState, useEffect, type ReactNode } from "react" import { fetchWorkspaces, createWorkspace, type Workspace } from "../api/workspaces" import { useTranslation } from "../hooks/useTranslation" import { toast } from "sonner" import { Button } from "../components/ui/button" import { Input } from "../components/ui/input" -interface WorkspaceContextType { - workspaces: Workspace[] - activeWorkspace: Workspace | null - setActiveWorkspace: (workspace: Workspace) => void - addWorkspace: (name: string) => Promise - isLoading: boolean -} +interface WorkspaceContextType { + workspaces: Workspace[] + activeWorkspace: Workspace | null + setActiveWorkspace: (workspace: Workspace | null) => void + addWorkspace: (name: string) => Promise + refreshWorkspaces: () => Promise + isLoading: boolean +} const WorkspaceContext = createContext(undefined) @@ -29,45 +30,58 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => { const [newWorkspaceName, setNewWorkspaceName] = useState("") const [isCreatingFirst, setIsCreatingFirst] = useState(false) - const isAuthenticated = !!localStorage.getItem("accessToken") - - useEffect(() => { - if (!isAuthenticated) { - setIsLoading(false) - return - } - - const loadWorkspaces = async () => { - try { - const response = await fetchWorkspaces() - - const data = Array.isArray(response) ? response : (response?.results || []) - setWorkspaces(data) - - if (data.length > 0) { - const storedId = localStorage.getItem("activeWorkspaceId") - const stored = data.find((w: Workspace) => w.id === storedId) - if (stored) { - setActiveWorkspaceState(stored) - } else { - setActiveWorkspaceState(data[0]) - localStorage.setItem("activeWorkspaceId", data[0].id) - } - } - } catch (error) { - console.error(error) - } finally { - setIsLoading(false) - } - } - - loadWorkspaces() - }, [isAuthenticated]) - - const setActiveWorkspace = (workspace: Workspace) => { - setActiveWorkspaceState(workspace) - localStorage.setItem("activeWorkspaceId", workspace.id) - } + const isAuthenticated = !!localStorage.getItem("accessToken") + + const refreshWorkspaces = async () => { + if (!isAuthenticated) { + setIsLoading(false) + return + } + + try { + setIsLoading(true) + const response = await fetchWorkspaces() + + const data = Array.isArray(response) ? response : (response?.results || []) + setWorkspaces(data) + + if (data.length > 0) { + const storedId = localStorage.getItem("activeWorkspaceId") + const stored = data.find((w: Workspace) => w.id === storedId) + if (stored) { + setActiveWorkspaceState(stored) + } else { + setActiveWorkspaceState(data[0]) + localStorage.setItem("activeWorkspaceId", data[0].id) + } + } else { + setActiveWorkspaceState(null) + localStorage.removeItem("activeWorkspaceId") + } + } catch (error) { + console.error(error) + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + if (!isAuthenticated) { + setIsLoading(false) + return + } + + void refreshWorkspaces() + }, [isAuthenticated]) + + const setActiveWorkspace = (workspace: Workspace | null) => { + setActiveWorkspaceState(workspace) + if (workspace) { + localStorage.setItem("activeWorkspaceId", workspace.id) + } else { + localStorage.removeItem("activeWorkspaceId") + } + } const addWorkspace = async (name: string) => { try { @@ -75,10 +89,10 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => { const newWs = await createWorkspace({ name, description: "" }) setWorkspaces((prev) => [...prev, newWs]) setActiveWorkspace(newWs) - toast.success(t.workspace?.createSuccess || "Workspace created!") - } catch (error) { - toast.error(t.workspace?.createError || "Failed to create workspace") - throw error + toast.success(t.workspace?.successCreate || t.workspace?.toast?.successCreate || "Workspace created!") + } catch (error) { + toast.error(t.workspace?.toast?.errorCreate || "Failed to create workspace") + throw error } finally { setIsCreatingFirst(false) setNewWorkspaceName("") @@ -123,10 +137,10 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => { } return ( - - {children} - - ) -} + + {children} + + ) +} diff --git a/src/pages/WorkspaceCreate.tsx b/src/pages/WorkspaceCreate.tsx index 145db64..e38da19 100644 --- a/src/pages/WorkspaceCreate.tsx +++ b/src/pages/WorkspaceCreate.tsx @@ -54,7 +54,7 @@ export default function WorkspaceCreate() { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [memberIdToDelete, setMemberIdToDelete] = useState(null); - const searchTimeoutRef = useRef(); + const searchTimeoutRef = useRef | null>(null); const hasUnsavedChanges = name.trim() !== '' || description.trim() !== '' || members.length > 0; useEffect(() => { diff --git a/src/pages/WorkspaceEdit.tsx b/src/pages/WorkspaceEdit.tsx index 7f28723..ac86fca 100644 --- a/src/pages/WorkspaceEdit.tsx +++ b/src/pages/WorkspaceEdit.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, Fragment, useMemo } from 'react'; +import React, { useState, useEffect, useRef, Fragment, useMemo, useCallback } from 'react'; import { useBlocker, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from '../hooks/useTranslation'; import { AlertCircle, UserPlus, Trash2, Shield } from 'lucide-react'; @@ -15,7 +15,7 @@ import { import { searchUserByExactMobile, type SearchedUser } from '../api/users'; import { useAppContext } from '../context/AppContext'; import { Button } from '../components/ui/button'; -import { InfiniteScroll } from '../components/infiniteScroll'; +import { InfiniteScroll } from '../components/InfiniteScroll'; import { Select } from '../components/ui/Select'; import { Input } from '../components/ui/input'; import { TextAreaInput } from '../components/ui/TextAreaInput'; @@ -67,12 +67,13 @@ export default function EditWorkspace() { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [memberIdToDelete, setMemberIdToDelete] = useState(null); - const searchTimeoutRef = useRef(); + const searchTimeoutRef = useRef | null>(null); const [initialData, setInitialData] = useState({ name: '', description: '', }); + const hasUnsavedChanges = useMemo(() => { if (isLoading) return false; @@ -100,13 +101,6 @@ export default function EditWorkspace() { return false; }); - useBlocker(({ currentLocation, nextLocation }) => { - if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) { - return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?"); - } - return false; - }); - useEffect(() => { if (id) loadData(); }, [id]); @@ -125,7 +119,9 @@ export default function EditWorkspace() { setMembers(results); setOffset(LIMIT); - setHasMore(!!membersData.next); + + // Robust hasMore check: use `.next` if available, otherwise check if array filled the limit + setHasMore(membersData.next ? true : results.length >= LIMIT); setInitialData({ name: workspaceData.name, @@ -139,22 +135,35 @@ export default function EditWorkspace() { } }; - const loadMoreMembers = async () => { + const loadMoreMembers = useCallback(async () => { if (isLoadingMembers || !hasMore || !id) return; try { setIsLoadingMembers(true); - const membersData = await fetchWorkspaceMemberships({ workspace: id, limit: `${LIMIT}`, offset: `${offset}` }); - const results = membersData.results || []; - setMembers((prev) => [...prev, ...results]); - setOffset((prev) => prev + LIMIT); - setHasMore(!!membersData.next); + // Send as pure numbers, axios handles them cleanly + const membersData = await fetchWorkspaceMemberships({ + workspace: id, + limit: LIMIT, + offset: offset + }); + const results = membersData.results || (Array.isArray(membersData) ? membersData : []); + + setMembers((prev) => { + // Safe deduplication to avoid React key warnings + const existingIds = new Set(prev.map(m => m.id)); + const newItems = results.filter((item: any) => !existingIds.has(item.id)); + return [...prev, ...newItems]; + }); + + setOffset(offset + LIMIT); + setHasMore(membersData.next ? true : results.length >= LIMIT); + } catch (error) { console.error("Failed to load more members", error); } finally { setIsLoadingMembers(false); } - }; + }, [id, isLoadingMembers, hasMore, offset]); useEffect(() => { if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); @@ -191,14 +200,11 @@ export default function EditWorkspace() { if (!name.trim() || !id) return; try { setIsSaving(true); - await updateWorkspace(id, { name, description }); - toast.success(t.workspace?.toast?.successUpdate || "Workspace updated successfully."); window.dispatchEvent(new CustomEvent('workspace_edited', { detail: { id, name, description } })); - navigate('/workspaces'); } catch (error) { toast.error(t.workspace?.toast?.errorUpdate || "Failed to update workspace."); @@ -210,11 +216,11 @@ export default function EditWorkspace() { const handleAddMember = async () => { if (!searchResult || !id) return; try { - const newMembership = await addWorkspaceMembership({ - workspace: id, - user: searchResult.id, - role: newMemberRole - }); + const newMembership = await addWorkspaceMembership({ + workspace: id, + user: String(searchResult.id), + role: newMemberRole + }); setMembers([newMembership, ...members]); toast.success(t.workspace?.toast?.successAdd || "Member added successfully."); setSearchQuery(''); @@ -264,8 +270,6 @@ export default function EditWorkspace() {
- - {/* --- ستون سمت چپ: فرم ویرایش --- */}
@@ -292,27 +296,17 @@ export default function EditWorkspace() { />
- -
- {/* --- ستون سمت راست: لیست اعضا --- */}
- - {/* بخش جستجو و هدر (ثابت در بالا) */}

{ t.workspace?.members || "Members" } @@ -391,7 +385,6 @@ export default function EditWorkspace() { )}

- {/* لیست اعضا (با قابلیت اسکرول مجزا) */}
{m.role === 'owner' && } - {m.role ? t.workspace?.roles?.[m.role] || m.role : "-"} - + {m.role && m.role in t.workspace.roles + ? t.workspace.roles[m.role as keyof typeof t.workspace.roles] + : m.role || "-"} + )} {canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner') && ( @@ -504,16 +499,10 @@ export default function EditWorkspace() { {t.workspace?.confirmDeleteMessage || "Are you sure you want to remove this member from the workspace?"}

- -