Real-time sync for Projects (#1512)

* Real-time sync for Projects

* Do not create project in a transaction

Update hooks trigger before the transaction is commited, which causes insert events to be dismissed because the row is isn't found
This commit is contained in:
Solomon
2025-12-17 18:25:34 +00:00
committed by GitHub
parent 4b4fdb9a60
commit a282bbdae4
21 changed files with 402 additions and 133 deletions

View File

@@ -33,18 +33,11 @@ import { useProjectMutations } from '@/hooks/useProjectMutations';
type Props = {
project: Project;
isFocused: boolean;
fetchProjects: () => void;
setError: (error: string) => void;
onEdit: (project: Project) => void;
};
function ProjectCard({
project,
isFocused,
fetchProjects,
setError,
onEdit,
}: Props) {
function ProjectCard({ project, isFocused, setError, onEdit }: Props) {
const navigate = useNavigateWithSearch();
const ref = useRef<HTMLDivElement>(null);
const handleOpenInEditor = useOpenProjectInEditor(project);
@@ -54,9 +47,6 @@ function ProjectCard({
const isSingleRepoProject = repos?.length === 1;
const { unlinkProject } = useProjectMutations({
onUnlinkSuccess: () => {
fetchProjects();
},
onUnlinkError: (error) => {
console.error('Failed to unlink project:', error);
setError('Failed to unlink project');
@@ -80,7 +70,6 @@ function ProjectCard({
try {
await projectsApi.delete(id);
fetchProjects();
} catch (error) {
console.error('Failed to delete project:', error);
setError('Failed to delete project');

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { useNavigateWithSearch } from '@/hooks';
import {
@@ -10,8 +11,8 @@ import {
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Project } from 'shared/types';
import { projectsApi } from '@/lib/api';
import { useProjects } from '@/hooks/useProjects';
import {
AlertCircle,
ArrowLeft,
@@ -29,26 +30,12 @@ interface ProjectDetailProps {
}
export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const { t } = useTranslation('projects');
const navigate = useNavigateWithSearch();
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const { projectsById, isLoading, error: projectsError } = useProjects();
const [deleteError, setDeleteError] = useState('');
const fetchProject = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await projectsApi.getById(projectId);
setProject(result);
} catch (error) {
console.error('Failed to fetch project:', error);
// @ts-expect-error it is type ApiError
setError(error.message || 'Failed to load project');
}
setLoading(false);
}, [projectId]);
const project = projectsById[projectId] || null;
const handleDelete = async () => {
if (!project) return;
@@ -65,7 +52,8 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
} catch (error) {
console.error('Failed to delete project:', error);
// @ts-expect-error it is type ApiError
setError(error.message || 'Failed to delete project');
setDeleteError(error.message || t('errors.deleteFailed'));
setTimeout(() => setDeleteError(''), 5000);
}
};
@@ -73,11 +61,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
navigate(`/settings/projects?projectId=${projectId}`);
};
useEffect(() => {
fetchProject();
}, [fetchProject]);
if (loading) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -86,7 +70,10 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
);
}
if (error || !project) {
if ((!project && !isLoading) || projectsError) {
const errorMsg = projectsError
? projectsError.message
: t('projectNotFound');
return (
<div className="space-y-4 py-12 px-4">
<Button variant="outline" onClick={onBack}>
@@ -99,10 +86,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<AlertCircle className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="mt-4 text-lg font-semibold">Project not found</h3>
<p className="mt-2 text-sm text-muted-foreground">
{error ||
"The project you're looking for doesn't exist or has been deleted."}
</p>
<p className="mt-2 text-sm text-muted-foreground">{errorMsg}</p>
<Button className="mt-4" onClick={onBack}>
Back to Projects
</Button>
@@ -149,10 +133,10 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
</div>
</div>
{error && (
{deleteError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
<AlertDescription>{deleteError}</AlertDescription>
</Alert>
)}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -7,40 +7,22 @@ import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Project } from 'shared/types';
import { ProjectFormDialog } from '@/components/dialogs/projects/ProjectFormDialog';
import { projectsApi } from '@/lib/api';
import { AlertCircle, Loader2, Plus } from 'lucide-react';
import ProjectCard from '@/components/projects/ProjectCard.tsx';
import { useKeyCreate, Scope } from '@/keyboard';
import { useProjects } from '@/hooks/useProjects';
export function ProjectList() {
const navigate = useNavigate();
const { t } = useTranslation('projects');
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false);
const { projects, isLoading, error: projectsError } = useProjects();
const [error, setError] = useState('');
const [focusedProjectId, setFocusedProjectId] = useState<string | null>(null);
const fetchProjects = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await projectsApi.getAll();
setProjects(result);
} catch (error) {
console.error('Failed to fetch projects:', error);
setError(t('errors.fetchFailed'));
} finally {
setLoading(false);
}
}, [t]);
const handleCreateProject = async () => {
try {
const result = await ProjectFormDialog.show({});
if (result === 'saved') {
fetchProjects();
}
if (result === 'saved') return;
} catch (error) {
// User cancelled - do nothing
}
@@ -55,15 +37,16 @@ export function ProjectList() {
// Set initial focus when projects are loaded
useEffect(() => {
if (projects.length > 0 && !focusedProjectId) {
if (projects.length === 0) {
setFocusedProjectId(null);
return;
}
if (!focusedProjectId || !projects.some((p) => p.id === focusedProjectId)) {
setFocusedProjectId(projects[0].id);
}
}, [projects, focusedProjectId]);
useEffect(() => {
fetchProjects();
}, [fetchProjects]);
return (
<div className="space-y-6 p-8 pb-16 md:pb-8 h-full overflow-auto">
<div className="flex justify-between items-center">
@@ -77,14 +60,16 @@ export function ProjectList() {
</Button>
</div>
{error && (
{(error || projectsError) && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
<AlertDescription>
{error || projectsError?.message || t('errors.fetchFailed')}
</AlertDescription>
</Alert>
)}
{loading ? (
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('loading')}
@@ -114,7 +99,6 @@ export function ProjectList() {
isFocused={focusedProjectId === project.id}
setError={setError}
onEdit={handleEditProject}
fetchProjects={fetchProjects}
/>
))}
</div>

View File

@@ -6,9 +6,8 @@ import {
useEffect,
} from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { projectsApi } from '@/lib/api';
import type { Project } from 'shared/types';
import { useProjects } from '@/hooks/useProjects';
interface ProjectContextValue {
projectId: string | undefined;
@@ -33,32 +32,28 @@ export function ProjectProvider({ children }: ProjectProviderProps) {
return match ? match[1] : undefined;
}, [location.pathname]);
const query = useQuery({
queryKey: ['project', projectId],
queryFn: () => projectsApi.getById(projectId!),
enabled: !!projectId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
const { projectsById, isLoading, error } = useProjects();
const project = projectId ? projectsById[projectId] : undefined;
const value = useMemo(
() => ({
projectId,
project: query.data,
isLoading: query.isLoading,
error: query.error,
isError: query.isError,
project,
isLoading,
error,
isError: !!error,
}),
[projectId, query.data, query.isLoading, query.error, query.isError]
[projectId, project, isLoading, error]
);
// Centralized page title management
useEffect(() => {
if (query.data) {
document.title = `${query.data.name} | vibe-kanban`;
if (project) {
document.title = `${project.name} | vibe-kanban`;
} else {
document.title = 'vibe-kanban';
}
}, [query.data]);
}, [project]);
return (
<ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>

View File

@@ -1,11 +1,48 @@
import { useQuery } from '@tanstack/react-query';
import { projectsApi } from '@/lib/api';
import { useCallback, useMemo } from 'react';
import { useJsonPatchWsStream } from './useJsonPatchWsStream';
import type { Project } from 'shared/types';
export function useProjects() {
return useQuery<Project[]>({
queryKey: ['projects'],
queryFn: () => projectsApi.getAll(),
staleTime: 30000, // Consider data fresh for 30 seconds
});
type ProjectsState = {
projects: Record<string, Project>;
};
export interface UseProjectsResult {
projects: Project[];
projectsById: Record<string, Project>;
isLoading: boolean;
isConnected: boolean;
error: Error | null;
}
export function useProjects(): UseProjectsResult {
const endpoint = '/api/projects/stream/ws';
const initialData = useCallback((): ProjectsState => ({ projects: {} }), []);
const { data, isConnected, error } = useJsonPatchWsStream<ProjectsState>(
endpoint,
true,
initialData
);
const projectsById = useMemo(() => data?.projects ?? {}, [data]);
const projects = useMemo(() => {
return Object.values(projectsById).sort(
(a, b) =>
new Date(b.created_at as unknown as string).getTime() -
new Date(a.created_at as unknown as string).getTime()
);
}, [projectsById]);
const projectsData = data ? projects : undefined;
const errorObj = useMemo(() => (error ? new Error(error) : null), [error]);
return {
projects: projectsData ?? [],
projectsById,
isLoading: !data && !error,
isConnected,
error: errorObj,
};
}

View File

@@ -5,7 +5,8 @@
"linkToOrganization": "Link to Remote Project",
"loading": "Loading projects...",
"errors": {
"fetchFailed": "Failed to fetch projects"
"fetchFailed": "Failed to fetch projects",
"deleteFailed": "Failed to delete project"
},
"empty": {
"title": "No projects yet",
@@ -44,6 +45,7 @@
}
},
"unlinkFromOrganization": "Unlink from Remote Project",
"projectNotFound": "The project you're looking for doesn't exist or has been deleted.",
"viewProject": "View Project",
"openInIDE": "Open in IDE",
"createdDate": "Created {{date}}",

View File

@@ -5,7 +5,8 @@
"linkToOrganization": "Vincular a Proyecto Remoto",
"loading": "Cargando proyectos...",
"errors": {
"fetchFailed": "Error al cargar proyectos"
"fetchFailed": "Error al cargar proyectos",
"deleteFailed": "Error al eliminar el proyecto"
},
"empty": {
"title": "Aún no hay proyectos",
@@ -44,6 +45,7 @@
}
},
"unlinkFromOrganization": "Desvincular de Proyecto Remoto",
"projectNotFound": "El proyecto que buscas no existe o ha sido eliminado.",
"viewProject": "Ver Proyecto",
"openInIDE": "Abrir en IDE",
"createdDate": "Creado {{date}}",

View File

@@ -5,7 +5,8 @@
"linkToOrganization": "リモートプロジェクトにリンク",
"loading": "プロジェクトを読み込み中...",
"errors": {
"fetchFailed": "プロジェクトの取得に失敗しました"
"fetchFailed": "プロジェクトの取得に失敗しました",
"deleteFailed": "プロジェクトの削除に失敗しました"
},
"empty": {
"title": "プロジェクトがありません",
@@ -44,6 +45,7 @@
}
},
"unlinkFromOrganization": "リモートプロジェクトからリンク解除",
"projectNotFound": "お探しのプロジェクトは存在しないか、削除されました。",
"viewProject": "プロジェクトを表示",
"openInIDE": "IDEで開く",
"createdDate": "作成日 {{date}}",

View File

@@ -5,7 +5,8 @@
"linkToOrganization": "원격 프로젝트에 연결",
"loading": "프로젝트 로딩 중...",
"errors": {
"fetchFailed": "프로젝트를 불러오지 못했습니다"
"fetchFailed": "프로젝트를 불러오지 못했습니다",
"deleteFailed": "프로젝트 삭제에 실패했습니다"
},
"empty": {
"title": "아직 프로젝트가 없습니다",
@@ -44,6 +45,7 @@
}
},
"unlinkFromOrganization": "원격 프로젝트에서 연결 해제",
"projectNotFound": "찾으시는 프로젝트가 존재하지 않거나 삭제되었습니다.",
"viewProject": "프로젝트 보기",
"openInIDE": "IDE에서 열기",
"createdDate": "생성일 {{date}}",

View File

@@ -5,7 +5,8 @@
"linkToOrganization": "链接到远程项目",
"loading": "加载项目中...",
"errors": {
"fetchFailed": "获取项目失败"
"fetchFailed": "获取项目失败",
"deleteFailed": "删除项目失败"
},
"empty": {
"title": "还没有项目",
@@ -44,6 +45,7 @@
}
},
"unlinkFromOrganization": "取消链接远程项目",
"projectNotFound": "您查找的项目不存在或已被删除。",
"viewProject": "查看项目",
"openInIDE": "在 IDE 中打开",
"createdDate": "创建于 {{date}}",

View File

@@ -233,16 +233,6 @@ export const handleApiResponse = async <T, E = T>(
// Project Management APIs
export const projectsApi = {
getAll: async (): Promise<Project[]> => {
const response = await makeRequest('/api/projects');
return handleApiResponse<Project[]>(response);
},
getById: async (id: string): Promise<Project> => {
const response = await makeRequest(`/api/projects/${id}`);
return handleApiResponse<Project>(response);
},
create: async (data: CreateProject): Promise<Project> => {
const response = await makeRequest('/api/projects', {
method: 'POST',

View File

@@ -137,7 +137,8 @@ export function OrganizationSettings() {
});
// Fetch all local projects
const { data: allProjects = [], isLoading: loadingProjects } = useProjects();
const { projects: allProjects = [], isLoading: loadingProjects } =
useProjects();
// Fetch remote projects for the selected organization
const { data: remoteProjects = [], isLoading: loadingRemoteProjects } =

View File

@@ -73,7 +73,7 @@ export function ProjectSettings() {
// Fetch all projects
const {
data: projects,
projects,
isLoading: projectsLoading,
error: projectsError,
} = useProjects();