mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-24 23:06:59 +08:00
* fix(docs): preserve gif playback position in lightbox and clean up ui components - Capture currentTime on click and seek lightbox video to match using useLayoutEffect - Convert lightboxStartTime from useState to useRef (no independent render needed) - Apply same fix to ActionVideo in action-media.tsx - Remove dead AnimatedBlocks component (zero imports) - Fix language-dropdown to derive currentLang during render instead of mirroring into state via effect - Replace template literals with cn() in faq.tsx and video.tsx * fix(chat): prevent @-mention menu focus loss and stabilize render identity Radix DropdownMenu's FocusScope was restoring focus from the search input to the content root whenever registered menu items mounted or unmounted inside the content, interrupting typing after a keystroke or two. - Keep the default tree always mounted under `hidden` instead of swapping subtrees when the filter activates. - Render filtered results as plain <button role="menuitem"> so they do not participate in Radix's menu Collection. - Add activeIndex state with ArrowUp/Down/Enter keyboard nav, mouse-hover sync, and scrollIntoView so the highlighted row stays visible and users can see what Enter will select. While tracing the cascade that compounded the bug: - Hoist `select` in useWorkflowMap / useWorkspacesQuery / useFolderMap to module scope so TanStack Query caches the select result across renders. - Guard setSelectedContexts([]) with a functional updater that bails out when already empty, preventing a fresh [] literal from invalidating consumers that key on reference identity. - Wrap WorkspaceHeader in React.memo so it bails out on parent renders once its (now-stable) props are unchanged. Made-with: Cursor * remove extraneous comments * cleanup * fix(chat): apply same setState bail-out to clearContexts for consistency Matches the invariant we already established for the message effect: calling setSelectedContexts([]) against an already-empty array emits a fresh [] reference (Object.is bails out are not reference-level), which cascades through consumers that key on selectedContexts identity. clearContexts is part of the hook's public API so callers can't know whether the list is empty — make it safe for them. Made-with: Cursor
445 lines
13 KiB
TypeScript
445 lines
13 KiB
TypeScript
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
|
|
/**
|
|
* Query key factory for workspace-related queries.
|
|
* Provides hierarchical cache keys for workspaces, settings, and permissions.
|
|
*/
|
|
type WorkspaceQueryScope = 'active' | 'archived' | 'all'
|
|
|
|
export const workspaceKeys = {
|
|
all: ['workspace'] as const,
|
|
lists: () => [...workspaceKeys.all, 'list'] as const,
|
|
list: (scope: WorkspaceQueryScope = 'active') =>
|
|
[...workspaceKeys.lists(), 'user', scope] as const,
|
|
details: () => [...workspaceKeys.all, 'detail'] as const,
|
|
detail: (id: string) => [...workspaceKeys.details(), id] as const,
|
|
settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const,
|
|
permissions: (id: string) => [...workspaceKeys.detail(id), 'permissions'] as const,
|
|
members: (id: string) => [...workspaceKeys.detail(id), 'members'] as const,
|
|
adminLists: () => [...workspaceKeys.all, 'adminList'] as const,
|
|
adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const,
|
|
}
|
|
|
|
/** Represents a workspace in the user's workspace list. */
|
|
export interface Workspace {
|
|
id: string
|
|
name: string
|
|
color?: string
|
|
logoUrl?: string | null
|
|
ownerId: string
|
|
role?: string
|
|
membershipId?: string
|
|
permissions?: 'admin' | 'write' | 'read' | null
|
|
}
|
|
|
|
interface WorkspacesResponse {
|
|
workspaces: Workspace[]
|
|
lastActiveWorkspaceId: string | null
|
|
}
|
|
|
|
async function fetchWorkspaces(
|
|
scope: WorkspaceQueryScope = 'active',
|
|
signal?: AbortSignal
|
|
): Promise<WorkspacesResponse> {
|
|
const response = await fetch(`/api/workspaces?scope=${scope}`, { signal })
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch workspaces')
|
|
}
|
|
|
|
const data = await response.json()
|
|
return {
|
|
workspaces: data.workspaces || [],
|
|
lastActiveWorkspaceId:
|
|
typeof data.lastActiveWorkspaceId === 'string' ? data.lastActiveWorkspaceId : null,
|
|
}
|
|
}
|
|
|
|
const selectWorkspaces = (data: WorkspacesResponse): Workspace[] => data.workspaces
|
|
|
|
/**
|
|
* Fetches the current user's workspaces.
|
|
* Returns only the workspace array. Use `useWorkspacesWithMetadata` when
|
|
* you also need `lastActiveWorkspaceId`.
|
|
*/
|
|
export function useWorkspacesQuery(enabled = true, scope: WorkspaceQueryScope = 'active') {
|
|
return useQuery({
|
|
queryKey: workspaceKeys.list(scope),
|
|
queryFn: ({ signal }) => fetchWorkspaces(scope, signal),
|
|
select: selectWorkspaces,
|
|
enabled,
|
|
staleTime: 30 * 1000,
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Fetches workspaces with the user's last active workspace ID.
|
|
* Used by the redirect page to determine which workspace to open.
|
|
*/
|
|
export function useWorkspacesWithMetadata(enabled = true) {
|
|
return useQuery({
|
|
queryKey: workspaceKeys.list('active'),
|
|
queryFn: ({ signal }) => fetchWorkspaces('active', signal),
|
|
enabled,
|
|
staleTime: 30 * 1000,
|
|
})
|
|
}
|
|
|
|
interface CreateWorkspaceParams {
|
|
name: string
|
|
}
|
|
|
|
/**
|
|
* Creates a new workspace.
|
|
* Merges the created row into the active list cache before invalidation so navigation
|
|
* cannot race a stale list (see workspace validation fallback in use-workspace-management).
|
|
*/
|
|
export function useCreateWorkspace() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ name }: CreateWorkspaceParams) => {
|
|
const response = await fetch('/api/workspaces', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name }),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
throw new Error(error.error || 'Failed to create workspace')
|
|
}
|
|
|
|
const data = await response.json()
|
|
return data.workspace as Workspace
|
|
},
|
|
onSuccess: (newWorkspace) => {
|
|
queryClient.setQueryData<WorkspacesResponse>(workspaceKeys.list('active'), (previous) => {
|
|
if (!previous) {
|
|
return { workspaces: [newWorkspace], lastActiveWorkspaceId: null }
|
|
}
|
|
if (previous.workspaces.some((w) => w.id === newWorkspace.id)) {
|
|
return previous
|
|
}
|
|
return { ...previous, workspaces: [newWorkspace, ...previous.workspaces] }
|
|
})
|
|
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
|
queryClient.invalidateQueries({ queryKey: workspaceKeys.adminLists() })
|
|
},
|
|
})
|
|
}
|
|
|
|
interface DeleteWorkspaceParams {
|
|
workspaceId: string
|
|
deleteTemplates?: boolean
|
|
}
|
|
|
|
/**
|
|
* Deletes a workspace.
|
|
* Automatically invalidates the workspace list cache on success.
|
|
*/
|
|
export function useDeleteWorkspace() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ workspaceId, deleteTemplates = false }: DeleteWorkspaceParams) => {
|
|
const response = await fetch(`/api/workspaces/${workspaceId}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ deleteTemplates }),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
throw new Error(error.error || 'Failed to delete workspace')
|
|
}
|
|
|
|
return response.json()
|
|
},
|
|
onSuccess: (_data, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
|
queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) })
|
|
},
|
|
})
|
|
}
|
|
|
|
interface UpdateWorkspaceParams {
|
|
workspaceId: string
|
|
name?: string
|
|
color?: string
|
|
logoUrl?: string | null
|
|
}
|
|
|
|
/**
|
|
* Updates a workspace's properties (name, color, etc.).
|
|
* Invalidates both the workspace list and the specific workspace detail cache.
|
|
*/
|
|
export function useUpdateWorkspace() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ workspaceId, ...updates }: UpdateWorkspaceParams) => {
|
|
const body = updates.name !== undefined ? { ...updates, name: updates.name.trim() } : updates
|
|
const response = await fetch(`/api/workspaces/${workspaceId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
throw new Error(error.error || 'Failed to update workspace')
|
|
}
|
|
|
|
return response.json()
|
|
},
|
|
onSuccess: (_data, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
|
queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) })
|
|
},
|
|
})
|
|
}
|
|
|
|
/** Represents a user with permissions in a workspace. */
|
|
export interface WorkspaceUser {
|
|
userId: string
|
|
email: string
|
|
name: string | null
|
|
image: string | null
|
|
permissionType: 'admin' | 'write' | 'read'
|
|
}
|
|
|
|
/** Workspace permissions data containing all users and their access levels. */
|
|
export interface WorkspacePermissions {
|
|
users: WorkspaceUser[]
|
|
total: number
|
|
}
|
|
|
|
async function fetchWorkspacePermissions(
|
|
workspaceId: string,
|
|
signal?: AbortSignal
|
|
): Promise<WorkspacePermissions> {
|
|
const response = await fetch(`/api/workspaces/${workspaceId}/permissions`, { signal })
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 404) {
|
|
throw new Error('Workspace not found or access denied')
|
|
}
|
|
if (response.status === 401) {
|
|
throw new Error('Authentication required')
|
|
}
|
|
throw new Error(`Failed to fetch permissions: ${response.statusText}`)
|
|
}
|
|
|
|
return response.json()
|
|
}
|
|
|
|
/**
|
|
* Fetches permissions for a specific workspace.
|
|
* @param workspaceId - The workspace ID to fetch permissions for
|
|
*/
|
|
export function useWorkspacePermissionsQuery(workspaceId: string | null | undefined) {
|
|
return useQuery({
|
|
queryKey: workspaceKeys.permissions(workspaceId ?? ''),
|
|
queryFn: ({ signal }) => fetchWorkspacePermissions(workspaceId as string, signal),
|
|
enabled: Boolean(workspaceId),
|
|
staleTime: 30 * 1000,
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
}
|
|
|
|
/** Lightweight member profile for UI display (avatars, owner cells). */
|
|
export interface WorkspaceMember {
|
|
userId: string
|
|
name: string
|
|
image: string | null
|
|
}
|
|
|
|
async function fetchWorkspaceMembers(
|
|
workspaceId: string,
|
|
signal?: AbortSignal
|
|
): Promise<WorkspaceMember[]> {
|
|
const response = await fetch(`/api/workspaces/${workspaceId}/members`, { signal })
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch workspace members')
|
|
}
|
|
|
|
const data = await response.json()
|
|
return data.members || []
|
|
}
|
|
|
|
/**
|
|
* Fetches lightweight member profiles (id, name, image) for a workspace.
|
|
* Use this for display purposes (avatars, owner cells) instead of the heavier permissions query.
|
|
*/
|
|
export function useWorkspaceMembersQuery(workspaceId: string | null | undefined) {
|
|
return useQuery({
|
|
queryKey: workspaceKeys.members(workspaceId ?? ''),
|
|
queryFn: ({ signal }) => fetchWorkspaceMembers(workspaceId as string, signal),
|
|
enabled: Boolean(workspaceId),
|
|
staleTime: 5 * 60 * 1000,
|
|
})
|
|
}
|
|
|
|
async function fetchWorkspaceSettings(workspaceId: string, signal?: AbortSignal) {
|
|
const [settingsResponse, permissionsResponse] = await Promise.all([
|
|
fetch(`/api/workspaces/${workspaceId}`, { signal }),
|
|
fetch(`/api/workspaces/${workspaceId}/permissions`, { signal }),
|
|
])
|
|
|
|
if (!settingsResponse.ok || !permissionsResponse.ok) {
|
|
throw new Error('Failed to fetch workspace settings')
|
|
}
|
|
|
|
const [settings, permissions] = await Promise.all([
|
|
settingsResponse.json(),
|
|
permissionsResponse.json(),
|
|
])
|
|
|
|
return {
|
|
settings,
|
|
permissions,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches workspace settings including permissions.
|
|
* @param workspaceId - The workspace ID to fetch settings for
|
|
*/
|
|
export function useWorkspaceSettings(workspaceId: string) {
|
|
return useQuery({
|
|
queryKey: workspaceKeys.settings(workspaceId),
|
|
queryFn: ({ signal }) => fetchWorkspaceSettings(workspaceId, signal),
|
|
enabled: !!workspaceId,
|
|
staleTime: 30 * 1000,
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
}
|
|
|
|
interface UpdateWorkspaceSettingsParams {
|
|
workspaceId: string
|
|
billedAccountUserId?: string
|
|
billingAccountUserEmail?: string
|
|
}
|
|
|
|
/**
|
|
* Updates workspace settings (e.g., billing configuration).
|
|
* Invalidates the workspace settings cache on success.
|
|
*/
|
|
export function useUpdateWorkspaceSettings() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ workspaceId, ...updates }: UpdateWorkspaceSettingsParams) => {
|
|
const response = await fetch(`/api/workspaces/${workspaceId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(updates),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
throw new Error(error.message || 'Failed to update workspace settings')
|
|
}
|
|
|
|
return response.json()
|
|
},
|
|
onSuccess: (_data, variables) => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: workspaceKeys.settings(variables.workspaceId),
|
|
})
|
|
},
|
|
})
|
|
}
|
|
|
|
/** Workspace with admin access metadata. */
|
|
export interface AdminWorkspace {
|
|
id: string
|
|
name: string
|
|
isOwner: boolean
|
|
ownerId?: string
|
|
canInvite: boolean
|
|
}
|
|
|
|
async function fetchAdminWorkspaces(
|
|
userId: string | undefined,
|
|
signal?: AbortSignal
|
|
): Promise<AdminWorkspace[]> {
|
|
if (!userId) {
|
|
return []
|
|
}
|
|
|
|
const workspacesResponse = await fetch('/api/workspaces', { signal })
|
|
if (!workspacesResponse.ok) {
|
|
throw new Error('Failed to fetch workspaces')
|
|
}
|
|
|
|
const workspacesData = await workspacesResponse.json()
|
|
const allUserWorkspaces = workspacesData.workspaces || []
|
|
|
|
const permissionPromises = allUserWorkspaces.map(
|
|
async (workspace: { id: string; name: string; isOwner?: boolean; ownerId?: string }) => {
|
|
try {
|
|
const permissionResponse = await fetch(`/api/workspaces/${workspace.id}/permissions`, {
|
|
signal,
|
|
})
|
|
if (!permissionResponse.ok) {
|
|
return null
|
|
}
|
|
const permissionData = await permissionResponse.json()
|
|
return { workspace, permissionData }
|
|
} catch (_error) {
|
|
return null
|
|
}
|
|
}
|
|
)
|
|
|
|
const results = await Promise.all(permissionPromises)
|
|
|
|
const adminWorkspaces: AdminWorkspace[] = []
|
|
for (const result of results) {
|
|
if (!result) continue
|
|
|
|
const { workspace, permissionData } = result
|
|
let hasAdminAccess = false
|
|
|
|
if (permissionData.users) {
|
|
const currentUserPermission = permissionData.users.find(
|
|
(user: { id: string; userId?: string; permissionType: string }) =>
|
|
user.id === userId || user.userId === userId
|
|
)
|
|
hasAdminAccess = currentUserPermission?.permissionType === 'admin'
|
|
}
|
|
|
|
const isOwner = workspace.isOwner || workspace.ownerId === userId
|
|
|
|
if (hasAdminAccess || isOwner) {
|
|
adminWorkspaces.push({
|
|
id: workspace.id,
|
|
name: workspace.name,
|
|
isOwner,
|
|
ownerId: workspace.ownerId,
|
|
canInvite: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
return adminWorkspaces
|
|
}
|
|
|
|
/**
|
|
* Fetches workspaces where the user has admin access.
|
|
* @param userId - The user ID to check admin access for
|
|
*/
|
|
export function useAdminWorkspaces(userId: string | undefined) {
|
|
return useQuery({
|
|
queryKey: workspaceKeys.adminList(userId),
|
|
queryFn: ({ signal }) => fetchAdminWorkspaces(userId, signal),
|
|
enabled: Boolean(userId),
|
|
staleTime: 60 * 1000,
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
}
|