mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-05 21:20:12 +00:00
feat: workspace switcher and misc
This commit is contained in:
235
src/platform/workspace/composables/useInviteUrlLoader.test.ts
Normal file
235
src/platform/workspace/composables/useInviteUrlLoader.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useInviteUrlLoader } from './useInviteUrlLoader'
|
||||
|
||||
/**
|
||||
* Unit tests for useInviteUrlLoader composable
|
||||
*
|
||||
* Tests the behavior of accepting workspace invites via URL query parameters:
|
||||
* - ?invite=TOKEN accepts the invite and shows success toast
|
||||
* - Invalid/missing token is handled gracefully
|
||||
* - API errors show error toast
|
||||
* - URL is cleaned up after processing
|
||||
*/
|
||||
|
||||
const preservedQueryMocks = vi.hoisted(() => ({
|
||||
clearPreservedQuery: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/navigation/preservedQueryManager',
|
||||
() => preservedQueryMocks
|
||||
)
|
||||
|
||||
// Mock toast store
|
||||
const mockToastAdd = vi.fn()
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: vi.fn((key: string, params?: Record<string, unknown>) => {
|
||||
if (key === 'workspace.inviteAccepted') return 'Invite Accepted'
|
||||
if (key === 'workspace.addedToWorkspace') {
|
||||
return `You have been added to ${params?.workspaceName}`
|
||||
}
|
||||
if (key === 'workspace.inviteFailed') return 'Failed to Accept Invite'
|
||||
if (key === 'g.error') return 'Error'
|
||||
return key
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useInviteUrlLoader', () => {
|
||||
const mockReplaceState = vi.fn()
|
||||
const mockLocation = {
|
||||
search: '',
|
||||
href: 'https://cloud.comfy.org/',
|
||||
origin: 'https://cloud.comfy.org'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLocation.search = ''
|
||||
mockLocation.href = 'https://cloud.comfy.org/'
|
||||
|
||||
// Mock location using vi.stubGlobal
|
||||
vi.stubGlobal('location', mockLocation)
|
||||
|
||||
// Mock history.replaceState
|
||||
vi.spyOn(window.history, 'replaceState').mockImplementation(
|
||||
mockReplaceState
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('getInviteTokenFromUrl', () => {
|
||||
it('returns null when no invite param present', () => {
|
||||
window.location.search = ''
|
||||
|
||||
const { getInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
const token = getInviteTokenFromUrl()
|
||||
|
||||
expect(token).toBeNull()
|
||||
})
|
||||
|
||||
it('returns token when invite param is present', () => {
|
||||
window.location.search = '?invite=test-token-123'
|
||||
|
||||
const { getInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
const token = getInviteTokenFromUrl()
|
||||
|
||||
expect(token).toBe('test-token-123')
|
||||
})
|
||||
|
||||
it('returns null for empty invite param', () => {
|
||||
window.location.search = '?invite='
|
||||
|
||||
const { getInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
const token = getInviteTokenFromUrl()
|
||||
|
||||
expect(token).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for whitespace-only invite param', () => {
|
||||
window.location.search = '?invite=%20%20'
|
||||
|
||||
const { getInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
const token = getInviteTokenFromUrl()
|
||||
|
||||
expect(token).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearInviteTokenFromUrl', () => {
|
||||
it('removes invite param from URL', () => {
|
||||
window.location.search = '?invite=test-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=test-token'
|
||||
|
||||
const { clearInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
clearInviteTokenFromUrl()
|
||||
|
||||
expect(mockReplaceState).toHaveBeenCalledWith(
|
||||
window.history.state,
|
||||
'',
|
||||
'https://cloud.comfy.org/'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves other query params when removing invite', () => {
|
||||
window.location.search = '?invite=test-token&other=param'
|
||||
window.location.href =
|
||||
'https://cloud.comfy.org/?invite=test-token&other=param'
|
||||
|
||||
const { clearInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
clearInviteTokenFromUrl()
|
||||
|
||||
expect(mockReplaceState).toHaveBeenCalledWith(
|
||||
window.history.state,
|
||||
'',
|
||||
'https://cloud.comfy.org/?other=param'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadInviteFromUrl', () => {
|
||||
it('does nothing when no invite param present', async () => {
|
||||
window.location.search = ''
|
||||
|
||||
const mockAcceptInvite = vi.fn()
|
||||
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||
await loadInviteFromUrl(mockAcceptInvite)
|
||||
|
||||
expect(mockAcceptInvite).not.toHaveBeenCalled()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockReplaceState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts invite and shows success toast on success', async () => {
|
||||
window.location.search = '?invite=valid-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=valid-token'
|
||||
|
||||
const mockAcceptInvite = vi.fn().mockResolvedValue({
|
||||
workspaceId: 'ws-123',
|
||||
workspaceName: 'Test Workspace'
|
||||
})
|
||||
|
||||
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||
await loadInviteFromUrl(mockAcceptInvite)
|
||||
|
||||
expect(mockAcceptInvite).toHaveBeenCalledWith('valid-token')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'success',
|
||||
summary: 'Invite Accepted',
|
||||
detail: 'You have been added to Test Workspace',
|
||||
life: 5000
|
||||
})
|
||||
expect(mockReplaceState).toHaveBeenCalled()
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'invite'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error toast when invite acceptance fails', async () => {
|
||||
window.location.search = '?invite=invalid-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=invalid-token'
|
||||
|
||||
const mockAcceptInvite = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Invalid invite'))
|
||||
|
||||
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||
await loadInviteFromUrl(mockAcceptInvite)
|
||||
|
||||
expect(mockAcceptInvite).toHaveBeenCalledWith('invalid-token')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'Failed to Accept Invite',
|
||||
detail: 'Error',
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
|
||||
it('cleans up URL even on error', async () => {
|
||||
window.location.search = '?invite=invalid-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=invalid-token'
|
||||
|
||||
const mockAcceptInvite = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Invalid invite'))
|
||||
|
||||
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||
await loadInviteFromUrl(mockAcceptInvite)
|
||||
|
||||
expect(mockReplaceState).toHaveBeenCalled()
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'invite'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears preserved query on success', async () => {
|
||||
window.location.search = '?invite=valid-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=valid-token'
|
||||
|
||||
const mockAcceptInvite = vi.fn().mockResolvedValue({
|
||||
workspaceId: 'ws-123',
|
||||
workspaceName: 'Test Workspace'
|
||||
})
|
||||
|
||||
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||
await loadInviteFromUrl(mockAcceptInvite)
|
||||
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'invite'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
84
src/platform/workspace/composables/useInviteUrlLoader.ts
Normal file
84
src/platform/workspace/composables/useInviteUrlLoader.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
type AcceptInviteFn = (
|
||||
token: string
|
||||
) => Promise<{ workspaceId: string; workspaceName: string }>
|
||||
|
||||
/**
|
||||
* Composable for loading workspace invites from URL query parameters
|
||||
*
|
||||
* Supports URLs like:
|
||||
* - /?invite=TOKEN (accepts workspace invite)
|
||||
*
|
||||
* Input validation:
|
||||
* - Token parameter must be a non-empty string
|
||||
*/
|
||||
export function useInviteUrlLoader() {
|
||||
const { t } = useI18n()
|
||||
const toastStore = useToastStore()
|
||||
const INVITE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.INVITE
|
||||
|
||||
/**
|
||||
* Gets the invite token from URL query parameters
|
||||
*/
|
||||
function getInviteTokenFromUrl(): string | null {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const token = params.get('invite')
|
||||
return token && token.trim().length > 0 ? token : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the invite parameter from URL without triggering navigation
|
||||
*/
|
||||
function clearInviteTokenFromUrl() {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('invite')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and accepts workspace invite from URL query parameters if present
|
||||
* Handles errors internally and shows appropriate user feedback
|
||||
*/
|
||||
async function loadInviteFromUrl(acceptInvite: AcceptInviteFn) {
|
||||
const token = getInviteTokenFromUrl()
|
||||
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await acceptInvite(token)
|
||||
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: t('workspace.inviteAccepted'),
|
||||
detail: t('workspace.addedToWorkspace', {
|
||||
workspaceName: result.workspaceName
|
||||
}),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[useInviteUrlLoader] Failed to accept invite:', error)
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('workspace.inviteFailed'),
|
||||
detail: t('g.error'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
clearInviteTokenFromUrl()
|
||||
clearPreservedQuery(INVITE_NAMESPACE)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getInviteTokenFromUrl,
|
||||
clearInviteTokenFromUrl,
|
||||
loadInviteFromUrl
|
||||
}
|
||||
}
|
||||
@@ -1,605 +0,0 @@
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import type {
|
||||
WorkspaceRole,
|
||||
WorkspaceType,
|
||||
WorkspaceWithRole
|
||||
} from '../api/workspaceApi'
|
||||
|
||||
// Re-export API types for consumers
|
||||
export type { WorkspaceRole, WorkspaceType, WorkspaceWithRole }
|
||||
|
||||
// Extended member type for UI (adds joinDate as Date)
|
||||
export interface WorkspaceMember {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role?: WorkspaceRole
|
||||
joinDate: Date
|
||||
}
|
||||
|
||||
// Extended invite type for UI (adds dates as Date objects)
|
||||
export interface PendingInvite {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
inviteDate: Date
|
||||
expiryDate: Date
|
||||
inviteLink: string
|
||||
}
|
||||
|
||||
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
|
||||
|
||||
interface WorkspaceState extends WorkspaceWithRole {
|
||||
isSubscribed: boolean
|
||||
subscriptionPlan: SubscriptionPlan
|
||||
members: WorkspaceMember[]
|
||||
pendingInvites: PendingInvite[]
|
||||
}
|
||||
|
||||
export interface AvailableWorkspace {
|
||||
id: string | null
|
||||
name: string
|
||||
type: WorkspaceType
|
||||
role: WorkspaceRole
|
||||
}
|
||||
|
||||
/** Permission flags for workspace actions */
|
||||
interface WorkspacePermissions {
|
||||
canViewOtherMembers: boolean
|
||||
canViewPendingInvites: boolean
|
||||
canInviteMembers: boolean
|
||||
canManageInvites: boolean
|
||||
canRemoveMembers: boolean
|
||||
canLeaveWorkspace: boolean
|
||||
canAccessWorkspaceMenu: boolean
|
||||
canManageSubscription: boolean
|
||||
}
|
||||
|
||||
/** UI configuration for workspace role */
|
||||
interface WorkspaceUIConfig {
|
||||
showMembersList: boolean
|
||||
showPendingTab: boolean
|
||||
showSearch: boolean
|
||||
showDateColumn: boolean
|
||||
showRoleBadge: boolean
|
||||
membersGridCols: string
|
||||
pendingGridCols: string
|
||||
headerGridCols: string
|
||||
showEditWorkspaceMenuItem: boolean
|
||||
workspaceMenuAction: 'leave' | 'delete' | null
|
||||
workspaceMenuDisabledTooltip: string | null
|
||||
}
|
||||
|
||||
// Role-based permissions mapping
|
||||
// Note: 'personal' type workspaces have owner role with restricted permissions
|
||||
function getPermissions(
|
||||
type: WorkspaceType,
|
||||
role: WorkspaceRole
|
||||
): WorkspacePermissions {
|
||||
if (type === 'personal') {
|
||||
return {
|
||||
canViewOtherMembers: false,
|
||||
canViewPendingInvites: false,
|
||||
canInviteMembers: false,
|
||||
canManageInvites: false,
|
||||
canRemoveMembers: false,
|
||||
canLeaveWorkspace: false,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'owner') {
|
||||
return {
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: true,
|
||||
canInviteMembers: true,
|
||||
canManageInvites: true,
|
||||
canRemoveMembers: true,
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true
|
||||
}
|
||||
}
|
||||
|
||||
// member role
|
||||
return {
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: false,
|
||||
canInviteMembers: false,
|
||||
canManageInvites: false,
|
||||
canRemoveMembers: false,
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: false
|
||||
}
|
||||
}
|
||||
|
||||
function getUIConfig(
|
||||
type: WorkspaceType,
|
||||
role: WorkspaceRole
|
||||
): WorkspaceUIConfig {
|
||||
if (type === 'personal') {
|
||||
return {
|
||||
showMembersList: false,
|
||||
showPendingTab: false,
|
||||
showSearch: false,
|
||||
showDateColumn: false,
|
||||
showRoleBadge: false,
|
||||
membersGridCols: 'grid-cols-1',
|
||||
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||
headerGridCols: 'grid-cols-1',
|
||||
showEditWorkspaceMenuItem: true,
|
||||
workspaceMenuAction: null,
|
||||
workspaceMenuDisabledTooltip: null
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'owner') {
|
||||
return {
|
||||
showMembersList: true,
|
||||
showPendingTab: true,
|
||||
showSearch: true,
|
||||
showDateColumn: true,
|
||||
showRoleBadge: true,
|
||||
membersGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||
headerGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
showEditWorkspaceMenuItem: true,
|
||||
workspaceMenuAction: 'delete',
|
||||
workspaceMenuDisabledTooltip:
|
||||
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
|
||||
}
|
||||
}
|
||||
|
||||
// member role
|
||||
return {
|
||||
showMembersList: true,
|
||||
showPendingTab: false,
|
||||
showSearch: true,
|
||||
showDateColumn: true,
|
||||
showRoleBadge: true,
|
||||
membersGridCols: 'grid-cols-[1fr_auto]',
|
||||
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||
headerGridCols: 'grid-cols-[1fr_auto]',
|
||||
showEditWorkspaceMenuItem: false,
|
||||
workspaceMenuAction: 'leave',
|
||||
workspaceMenuDisabledTooltip: null
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_OWNED_WORKSPACES = 10
|
||||
const MAX_WORKSPACE_MEMBERS = 50
|
||||
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 10)
|
||||
}
|
||||
|
||||
function createPersonalWorkspace(): WorkspaceState {
|
||||
return {
|
||||
id: 'personal',
|
||||
name: 'Personal workspace',
|
||||
type: 'personal',
|
||||
role: 'owner',
|
||||
isSubscribed: true,
|
||||
subscriptionPlan: null,
|
||||
members: [],
|
||||
pendingInvites: []
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MODULE-LEVEL STATE
|
||||
// Persists across component lifecycle - not disposed when components unmount
|
||||
// =============================================================================
|
||||
const _workspaces = shallowRef<WorkspaceState[]>([createPersonalWorkspace()])
|
||||
const _currentWorkspaceIndex = ref(0)
|
||||
const _activeTab = ref<string>('plan')
|
||||
|
||||
// Helper to get current workspace
|
||||
function getCurrentWorkspace(): WorkspaceState {
|
||||
return _workspaces.value[_currentWorkspaceIndex.value]
|
||||
}
|
||||
|
||||
// Helper to update current workspace immutably
|
||||
function updateCurrentWorkspace(updates: Partial<WorkspaceState>) {
|
||||
const index = _currentWorkspaceIndex.value
|
||||
const updated = { ..._workspaces.value[index], ...updates }
|
||||
_workspaces.value = [
|
||||
..._workspaces.value.slice(0, index),
|
||||
updated,
|
||||
..._workspaces.value.slice(index + 1)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal composable implementation for workspace management.
|
||||
* Uses module-level state to persist across component lifecycle.
|
||||
* Will integrate with useWorkspaceAuth once that PR lands.
|
||||
*/
|
||||
function useWorkspaceInternal() {
|
||||
const { userDisplayName, userEmail } = useCurrentUser()
|
||||
|
||||
// Computed properties derived from module-level state
|
||||
const currentWorkspace = computed(() => getCurrentWorkspace())
|
||||
const workspaceId = computed(() => currentWorkspace.value?.id ?? null)
|
||||
const workspaceName = computed(
|
||||
() => currentWorkspace.value?.name ?? 'Personal workspace'
|
||||
)
|
||||
const workspaceType = computed(
|
||||
() => currentWorkspace.value?.type ?? 'personal'
|
||||
)
|
||||
const workspaceRole = computed(() => currentWorkspace.value?.role ?? 'owner')
|
||||
const activeTab = computed(() => _activeTab.value)
|
||||
|
||||
const isPersonalWorkspace = computed(
|
||||
() => currentWorkspace.value?.type === 'personal'
|
||||
)
|
||||
|
||||
const isWorkspaceSubscribed = computed(
|
||||
() => currentWorkspace.value?.isSubscribed ?? false
|
||||
)
|
||||
|
||||
const subscriptionPlan = computed(
|
||||
() => currentWorkspace.value?.subscriptionPlan ?? null
|
||||
)
|
||||
|
||||
const permissions = computed<WorkspacePermissions>(() =>
|
||||
getPermissions(workspaceType.value, workspaceRole.value)
|
||||
)
|
||||
|
||||
const uiConfig = computed<WorkspaceUIConfig>(() =>
|
||||
getUIConfig(workspaceType.value, workspaceRole.value)
|
||||
)
|
||||
|
||||
// For personal workspace, always show current user as the only member
|
||||
const members = computed<WorkspaceMember[]>(() => {
|
||||
if (isPersonalWorkspace.value) {
|
||||
return [
|
||||
{
|
||||
id: 'current-user',
|
||||
name: userDisplayName.value ?? 'You',
|
||||
email: userEmail.value ?? '',
|
||||
role: 'owner',
|
||||
joinDate: new Date()
|
||||
}
|
||||
]
|
||||
}
|
||||
return currentWorkspace.value?.members ?? []
|
||||
})
|
||||
|
||||
const pendingInvites = computed(
|
||||
() => currentWorkspace.value?.pendingInvites ?? []
|
||||
)
|
||||
|
||||
const totalMemberSlots = computed(
|
||||
() => members.value.length + pendingInvites.value.length
|
||||
)
|
||||
|
||||
const isInviteLimitReached = computed(
|
||||
() => totalMemberSlots.value >= MAX_WORKSPACE_MEMBERS
|
||||
)
|
||||
|
||||
const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
|
||||
_workspaces.value.map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
type: w.type,
|
||||
role: w.role
|
||||
}))
|
||||
)
|
||||
|
||||
const ownedWorkspacesCount = computed(
|
||||
() => _workspaces.value.filter((w) => w.role === 'owner').length
|
||||
)
|
||||
|
||||
const canCreateWorkspace = computed(
|
||||
() => ownedWorkspacesCount.value < MAX_OWNED_WORKSPACES
|
||||
)
|
||||
|
||||
// Tab management
|
||||
function setActiveTab(tab: string | number) {
|
||||
_activeTab.value = String(tab)
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different workspace by ID.
|
||||
* TODO: Integrate with useWorkspaceAuth.switchWorkspace() when PR lands
|
||||
*/
|
||||
function switchWorkspace(workspace: AvailableWorkspace) {
|
||||
const index = _workspaces.value.findIndex((w) => w.id === workspace.id)
|
||||
if (index !== -1) {
|
||||
_currentWorkspaceIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new workspace.
|
||||
* TODO: Replace with workspaceApi.create() call
|
||||
*/
|
||||
function createWorkspace(name: string): WorkspaceState {
|
||||
const newWorkspace: WorkspaceState = {
|
||||
id: `workspace-${generateId()}`,
|
||||
name,
|
||||
type: 'team',
|
||||
role: 'owner',
|
||||
isSubscribed: false,
|
||||
subscriptionPlan: null,
|
||||
members: [],
|
||||
pendingInvites: [
|
||||
// Stub invite for testing
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'PendingUser',
|
||||
email: 'pending@example.com',
|
||||
inviteDate: new Date(),
|
||||
expiryDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
inviteLink: `https://cloud.comfy.org/workspace/invite/${generateId()}`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
_workspaces.value = [..._workspaces.value, newWorkspace]
|
||||
_currentWorkspaceIndex.value = _workspaces.value.length - 1
|
||||
|
||||
return newWorkspace
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe the current workspace to a plan.
|
||||
* TODO: Replace with subscription API call
|
||||
*/
|
||||
function subscribeWorkspace(plan: SubscriptionPlan = 'PRO_MONTHLY') {
|
||||
updateCurrentWorkspace({
|
||||
isSubscribed: true,
|
||||
subscriptionPlan: plan
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the current workspace (owner only).
|
||||
* TODO: Replace with workspaceApi.delete() call
|
||||
*/
|
||||
function deleteWorkspace() {
|
||||
const current = getCurrentWorkspace()
|
||||
if (current.role === 'owner' && current.type === 'team') {
|
||||
_workspaces.value = _workspaces.value.filter((w) => w.id !== current.id)
|
||||
_currentWorkspaceIndex.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave the current workspace (member only).
|
||||
* TODO: Replace with workspaceApi.leave() call
|
||||
*/
|
||||
function leaveWorkspace() {
|
||||
const current = getCurrentWorkspace()
|
||||
if (current.role === 'member') {
|
||||
_workspaces.value = _workspaces.value.filter((w) => w.id !== current.id)
|
||||
_currentWorkspaceIndex.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workspace name.
|
||||
* TODO: Replace with workspaceApi.update() call
|
||||
*/
|
||||
function updateWorkspaceName(name: string) {
|
||||
updateCurrentWorkspace({ name })
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to the current workspace.
|
||||
* TODO: This happens via invite acceptance on backend
|
||||
*/
|
||||
function addMember(member: Omit<WorkspaceMember, 'id' | 'joinDate'>) {
|
||||
const current = getCurrentWorkspace()
|
||||
const newMember: WorkspaceMember = {
|
||||
...member,
|
||||
id: generateId(),
|
||||
joinDate: new Date()
|
||||
}
|
||||
updateCurrentWorkspace({
|
||||
members: [...current.members, newMember]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from the current workspace.
|
||||
* TODO: Replace with workspaceApi.removeMember() call
|
||||
*/
|
||||
function removeMember(memberId: string) {
|
||||
const current = getCurrentWorkspace()
|
||||
updateCurrentWorkspace({
|
||||
members: current.members.filter((m) => m.id !== memberId)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a pending invite.
|
||||
* TODO: Replace with workspaceApi.revokeInvite() call
|
||||
*/
|
||||
function revokeInvite(inviteId: string) {
|
||||
const current = getCurrentWorkspace()
|
||||
updateCurrentWorkspace({
|
||||
pendingInvites: current.pendingInvites.filter((i) => i.id !== inviteId)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a pending invite (for demo/testing).
|
||||
*/
|
||||
function acceptInvite(inviteId: string) {
|
||||
const current = getCurrentWorkspace()
|
||||
const invite = current.pendingInvites.find((i) => i.id === inviteId)
|
||||
if (invite) {
|
||||
const updatedPending = current.pendingInvites.filter(
|
||||
(i) => i.id !== inviteId
|
||||
)
|
||||
const newMember: WorkspaceMember = {
|
||||
id: generateId(),
|
||||
name: invite.name,
|
||||
email: invite.email,
|
||||
role: 'member',
|
||||
joinDate: new Date()
|
||||
}
|
||||
updateCurrentWorkspace({
|
||||
pendingInvites: updatedPending,
|
||||
members: [...current.members, newMember]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Async API methods (stubs for now)
|
||||
|
||||
async function fetchMembers(): Promise<WorkspaceMember[]> {
|
||||
// TODO: Replace with workspaceApi.get() call
|
||||
return members.value
|
||||
}
|
||||
|
||||
async function fetchPendingInvites(): Promise<PendingInvite[]> {
|
||||
// TODO: Replace with workspaceApi.get() call
|
||||
return pendingInvites.value
|
||||
}
|
||||
|
||||
async function copyInviteLink(inviteId: string): Promise<string> {
|
||||
const invite = pendingInvites.value.find((i) => i.id === inviteId)
|
||||
if (invite) {
|
||||
await navigator.clipboard.writeText(invite.inviteLink)
|
||||
return invite.inviteLink
|
||||
}
|
||||
throw new Error('Invite not found')
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy invite link and simulate member accepting (for demo).
|
||||
*/
|
||||
async function copyInviteLinkAndAccept(inviteId: string): Promise<string> {
|
||||
const invite = pendingInvites.value.find((i) => i.id === inviteId)
|
||||
if (invite) {
|
||||
await navigator.clipboard.writeText(invite.inviteLink)
|
||||
revokeInvite(inviteId)
|
||||
addMember({
|
||||
name: invite.name,
|
||||
email: invite.email,
|
||||
role: 'member'
|
||||
})
|
||||
return invite.inviteLink
|
||||
}
|
||||
throw new Error('Invite not found')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an invite link for a given email.
|
||||
* TODO: Replace with workspaceApi.createInvite() call
|
||||
*/
|
||||
async function createInviteLink(email: string): Promise<string> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
const inviteId = generateId()
|
||||
const inviteLink = `https://cloud.comfy.org/workspace/invite/${inviteId}`
|
||||
|
||||
const current = getCurrentWorkspace()
|
||||
const newInvite: PendingInvite = {
|
||||
id: inviteId,
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
inviteDate: new Date(),
|
||||
expiryDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
inviteLink
|
||||
}
|
||||
updateCurrentWorkspace({
|
||||
pendingInvites: [...current.pendingInvites, newInvite]
|
||||
})
|
||||
|
||||
return inviteLink
|
||||
}
|
||||
|
||||
// Dev helpers for testing UI states
|
||||
function setMockRole(role: WorkspaceRole) {
|
||||
updateCurrentWorkspace({ role })
|
||||
}
|
||||
|
||||
function setMockSubscribed(subscribed: boolean) {
|
||||
updateCurrentWorkspace({ isSubscribed: subscribed })
|
||||
}
|
||||
|
||||
function setMockType(type: WorkspaceType) {
|
||||
updateCurrentWorkspace({ type })
|
||||
}
|
||||
|
||||
// Expose to window for dev testing
|
||||
if (typeof window !== 'undefined') {
|
||||
const w = window as Window & {
|
||||
__setWorkspaceRole?: typeof setMockRole
|
||||
__setWorkspaceSubscribed?: typeof setMockSubscribed
|
||||
__setWorkspaceType?: typeof setMockType
|
||||
}
|
||||
w.__setWorkspaceRole = setMockRole
|
||||
w.__setWorkspaceSubscribed = setMockSubscribed
|
||||
w.__setWorkspaceType = setMockType
|
||||
}
|
||||
|
||||
return {
|
||||
// Current workspace state
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
workspaceType,
|
||||
workspaceRole,
|
||||
activeTab,
|
||||
isPersonalWorkspace,
|
||||
isWorkspaceSubscribed,
|
||||
subscriptionPlan,
|
||||
permissions,
|
||||
uiConfig,
|
||||
|
||||
// Tab management
|
||||
setActiveTab,
|
||||
|
||||
// Workspace switching/management
|
||||
availableWorkspaces,
|
||||
ownedWorkspacesCount,
|
||||
canCreateWorkspace,
|
||||
switchWorkspace,
|
||||
createWorkspace,
|
||||
subscribeWorkspace,
|
||||
deleteWorkspace,
|
||||
leaveWorkspace,
|
||||
updateWorkspaceName,
|
||||
|
||||
// Members
|
||||
members,
|
||||
pendingInvites,
|
||||
totalMemberSlots,
|
||||
isInviteLimitReached,
|
||||
fetchMembers,
|
||||
fetchPendingInvites,
|
||||
revokeInvite,
|
||||
acceptInvite,
|
||||
copyInviteLink,
|
||||
copyInviteLinkAndAccept,
|
||||
createInviteLink,
|
||||
addMember,
|
||||
removeMember,
|
||||
|
||||
// Dev helpers
|
||||
setMockRole,
|
||||
setMockSubscribed,
|
||||
setMockType
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared composable for workspace management.
|
||||
* Uses module-level state to persist across component lifecycle.
|
||||
* The createSharedComposable wrapper ensures computed properties
|
||||
* are shared efficiently across components.
|
||||
*
|
||||
* Future integration:
|
||||
* - Will consume useWorkspaceAuth for authentication context
|
||||
* - Will use workspaceApi for backend calls
|
||||
*/
|
||||
export const useWorkspace = createSharedComposable(useWorkspaceInternal)
|
||||
177
src/platform/workspace/composables/useWorkspaceUI.ts
Normal file
177
src/platform/workspace/composables/useWorkspaceUI.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
|
||||
import { useWorkspaceStore } from '../stores/workspaceStore'
|
||||
|
||||
/** Permission flags for workspace actions */
|
||||
export interface WorkspacePermissions {
|
||||
canViewOtherMembers: boolean
|
||||
canViewPendingInvites: boolean
|
||||
canInviteMembers: boolean
|
||||
canManageInvites: boolean
|
||||
canRemoveMembers: boolean
|
||||
canLeaveWorkspace: boolean
|
||||
canAccessWorkspaceMenu: boolean
|
||||
canManageSubscription: boolean
|
||||
}
|
||||
|
||||
/** UI configuration for workspace role */
|
||||
export interface WorkspaceUIConfig {
|
||||
showMembersList: boolean
|
||||
showPendingTab: boolean
|
||||
showSearch: boolean
|
||||
showDateColumn: boolean
|
||||
showRoleBadge: boolean
|
||||
membersGridCols: string
|
||||
pendingGridCols: string
|
||||
headerGridCols: string
|
||||
showEditWorkspaceMenuItem: boolean
|
||||
workspaceMenuAction: 'leave' | 'delete' | null
|
||||
workspaceMenuDisabledTooltip: string | null
|
||||
}
|
||||
|
||||
function getPermissions(
|
||||
type: WorkspaceType,
|
||||
role: WorkspaceRole
|
||||
): WorkspacePermissions {
|
||||
if (type === 'personal') {
|
||||
return {
|
||||
canViewOtherMembers: false,
|
||||
canViewPendingInvites: false,
|
||||
canInviteMembers: false,
|
||||
canManageInvites: false,
|
||||
canRemoveMembers: false,
|
||||
canLeaveWorkspace: false,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'owner') {
|
||||
return {
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: true,
|
||||
canInviteMembers: true,
|
||||
canManageInvites: true,
|
||||
canRemoveMembers: true,
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true
|
||||
}
|
||||
}
|
||||
|
||||
// member role
|
||||
return {
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: false,
|
||||
canInviteMembers: false,
|
||||
canManageInvites: false,
|
||||
canRemoveMembers: false,
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: false
|
||||
}
|
||||
}
|
||||
|
||||
function getUIConfig(
|
||||
type: WorkspaceType,
|
||||
role: WorkspaceRole
|
||||
): WorkspaceUIConfig {
|
||||
if (type === 'personal') {
|
||||
return {
|
||||
showMembersList: false,
|
||||
showPendingTab: false,
|
||||
showSearch: false,
|
||||
showDateColumn: false,
|
||||
showRoleBadge: false,
|
||||
membersGridCols: 'grid-cols-1',
|
||||
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||
headerGridCols: 'grid-cols-1',
|
||||
showEditWorkspaceMenuItem: true,
|
||||
workspaceMenuAction: null,
|
||||
workspaceMenuDisabledTooltip: null
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'owner') {
|
||||
return {
|
||||
showMembersList: true,
|
||||
showPendingTab: true,
|
||||
showSearch: true,
|
||||
showDateColumn: true,
|
||||
showRoleBadge: true,
|
||||
membersGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||
headerGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
showEditWorkspaceMenuItem: true,
|
||||
workspaceMenuAction: 'delete',
|
||||
workspaceMenuDisabledTooltip:
|
||||
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
|
||||
}
|
||||
}
|
||||
|
||||
// member role
|
||||
return {
|
||||
showMembersList: true,
|
||||
showPendingTab: false,
|
||||
showSearch: true,
|
||||
showDateColumn: true,
|
||||
showRoleBadge: true,
|
||||
membersGridCols: 'grid-cols-[1fr_auto]',
|
||||
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||
headerGridCols: 'grid-cols-[1fr_auto]',
|
||||
showEditWorkspaceMenuItem: false,
|
||||
workspaceMenuAction: 'leave',
|
||||
workspaceMenuDisabledTooltip: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation of UI configuration composable.
|
||||
*/
|
||||
function useWorkspaceUIInternal() {
|
||||
const store = useWorkspaceStore()
|
||||
|
||||
// Tab management (shared UI state)
|
||||
const activeTab = ref<string>('plan')
|
||||
|
||||
function setActiveTab(tab: string | number) {
|
||||
activeTab.value = String(tab)
|
||||
}
|
||||
|
||||
const workspaceType = computed<WorkspaceType>(
|
||||
() => store.activeWorkspace?.type ?? 'personal'
|
||||
)
|
||||
|
||||
const workspaceRole = computed<WorkspaceRole>(
|
||||
() => store.activeWorkspace?.role ?? 'owner'
|
||||
)
|
||||
|
||||
const permissions = computed<WorkspacePermissions>(() =>
|
||||
getPermissions(workspaceType.value, workspaceRole.value)
|
||||
)
|
||||
|
||||
const uiConfig = computed<WorkspaceUIConfig>(() =>
|
||||
getUIConfig(workspaceType.value, workspaceRole.value)
|
||||
)
|
||||
|
||||
return {
|
||||
// Tab management
|
||||
activeTab: computed(() => activeTab.value),
|
||||
setActiveTab,
|
||||
|
||||
// Permissions and config
|
||||
permissions,
|
||||
uiConfig,
|
||||
workspaceType,
|
||||
workspaceRole
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI configuration composable derived from workspace state.
|
||||
* Controls what UI elements are visible/enabled based on role and workspace type.
|
||||
* Uses createSharedComposable to ensure tab state is shared across components.
|
||||
*/
|
||||
export const useWorkspaceUI = createSharedComposable(useWorkspaceUIInternal)
|
||||
Reference in New Issue
Block a user