refactor(workspaces): normalize workspace bootstrap and edit flows
This commit is contained in:
@@ -54,7 +54,7 @@ export default function WorkspaceCreate() {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
||||
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasUnsavedChanges = name.trim() !== '' || description.trim() !== '' || members.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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() {
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
|
||||
|
||||
{/* --- ستون سمت چپ: فرم ویرایش --- */}
|
||||
<div className="w-full lg:w-1/3 lg:max-w-md flex flex-col shrink-0 overflow-y-auto bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col h-full p-6">
|
||||
<div className="mb-4">
|
||||
@@ -292,27 +296,17 @@ export default function EditWorkspace() {
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-6 flex justify-end gap-3 border-t border-slate-100 dark:border-slate-800 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/workspaces')}
|
||||
>
|
||||
<Button type="button" variant="ghost" onClick={() => navigate('/workspaces')}>
|
||||
{t.actions?.cancel || "Cancel"}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving || !name.trim()}
|
||||
>
|
||||
<Button type="submit" disabled={isSaving || !name.trim()}>
|
||||
{isSaving ? (t.workspace?.loading || "Saving...") : (t.workspace?.save || "Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* --- ستون سمت راست: لیست اعضا --- */}
|
||||
<div className="w-full lg:w-2/3 flex-1 flex flex-col min-h-100 lg:min-h-0 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
||||
|
||||
{/* بخش جستجو و هدر (ثابت در بالا) */}
|
||||
<div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
||||
{ t.workspace?.members || "Members" }
|
||||
@@ -391,7 +385,6 @@ export default function EditWorkspace() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* لیست اعضا (با قابلیت اسکرول مجزا) */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-3 bg-slate-50/30 dark:bg-slate-900/30">
|
||||
<InfiniteScroll
|
||||
onLoadMore={loadMoreMembers}
|
||||
@@ -438,8 +431,10 @@ export default function EditWorkspace() {
|
||||
) : (
|
||||
<span className="text-xs px-2 py-1 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded-md capitalize flex items-center gap-1">
|
||||
{m.role === 'owner' && <Shield className="w-3 h-3" />}
|
||||
{m.role ? t.workspace?.roles?.[m.role] || m.role : "-"}
|
||||
</span>
|
||||
{m.role && m.role in t.workspace.roles
|
||||
? t.workspace.roles[m.role as keyof typeof t.workspace.roles]
|
||||
: m.role || "-"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{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?"}
|
||||
</p>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setIsDeleteDialogOpen(false)}
|
||||
>
|
||||
<Button variant="secondary" onClick={() => setIsDeleteDialogOpen(false)}>
|
||||
{t.actions?.cancel || "Cancel"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteMember}
|
||||
>
|
||||
<Button variant="destructive" onClick={handleDeleteMember}>
|
||||
{t.actions?.delete || "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user