diff --git a/docs/TASKS/workspaces/001-all-workspaces-selector.md b/docs/TASKS/workspaces/001-all-workspaces-selector.md new file mode 100644 index 0000000..e1ac853 --- /dev/null +++ b/docs/TASKS/workspaces/001-all-workspaces-selector.md @@ -0,0 +1,33 @@ +# All Workspaces Selector + +## Feature + +Workspace navigation and cross-workspace content visibility. + +## Goal + +Allow users with access to multiple workspaces to select an "All Workspaces" scope from the workspace selector and view combined workspace data in list/calendar style views. + +## Scope + +- Add an explicit all-workspaces selection state to the frontend workspace store. +- Add an "All Workspaces" entry as the first workspace selector item. +- Add per-workspace visibility toggles for the all-workspaces aggregate scope. +- Fetch list data without `workspaceId` when all workspaces are selected. +- Filter all-workspaces list data to the currently visible workspace set. +- Keep creation and workspace settings actions scoped to a concrete workspace. + +## Likely Files + +- `frontend/src/features/workspaces/stores/workspaceStore.js` +- `frontend/src/layouts/main/WorkspaceSelector.vue` +- `frontend/src/features/content/stores/contentItemsStore.js` +- `frontend/src/features/campaigns/stores/campaignsStore.js` +- `frontend/src/features/clients/stores/clientsStore.js` +- `frontend/src/features/channels/stores/channelsStore.js` +- `frontend/src/locales/en.json` +- `frontend/src/locales/fr.json` + +## Validation + +- `cd frontend && npm run build` diff --git a/frontend/src/features/campaigns/stores/campaignsStore.js b/frontend/src/features/campaigns/stores/campaignsStore.js index c992bca..894e489 100644 --- a/frontend/src/features/campaigns/stores/campaignsStore.js +++ b/frontend/src/features/campaigns/stores/campaignsStore.js @@ -15,7 +15,7 @@ export const useCampaignsStore = defineStore('campaigns', () => { const error = ref(null); async function fetchCampaigns() { - if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) { + if (!authStore.isAuthenticated) { campaigns.value = []; error.value = null; return; @@ -27,11 +27,13 @@ export const useCampaignsStore = defineStore('campaigns', () => { try { const response = await client.get('/api/campaigns', { params: { - workspaceId: workspaceStore.activeWorkspaceId, + workspaceId: workspaceStore.activeWorkspaceId ?? undefined, }, }); - campaigns.value = response.data ?? []; + campaigns.value = (response.data ?? []).filter(campaign => + workspaceStore.isWorkspaceVisible(campaign.workspaceId) + ); } catch (fetchError) { console.error('Failed to fetch campaigns:', fetchError); campaigns.value = []; @@ -75,9 +77,9 @@ export const useCampaignsStore = defineStore('campaigns', () => { } watch( - () => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId], - async ([isAuthenticated, workspaceId]) => { - if (!isAuthenticated || !workspaceId) { + () => [authStore.isAuthenticated, workspaceStore.workspaceScopeKey], + async ([isAuthenticated]) => { + if (!isAuthenticated) { campaigns.value = []; error.value = null; return; diff --git a/frontend/src/features/channels/stores/channelsStore.js b/frontend/src/features/channels/stores/channelsStore.js index 205101a..e6b04fe 100644 --- a/frontend/src/features/channels/stores/channelsStore.js +++ b/frontend/src/features/channels/stores/channelsStore.js @@ -14,6 +14,7 @@ export const useChannelsStore = defineStore('channels', () => { const isCreating = ref(false); const error = ref(null); const loadedWorkspaceId = ref(null); + const allWorkspacesKey = '__all__'; const availableNetworks = [ 'Instagram', @@ -28,15 +29,16 @@ export const useChannelsStore = defineStore('channels', () => { async function fetchChannels({ force = false } = {}) { const currentWorkspaceId = workspaceStore.activeWorkspaceId; + const currentScopeKey = currentWorkspaceId ?? workspaceStore.workspaceScopeKey ?? allWorkspacesKey; - if (!authStore.isAuthenticated || !currentWorkspaceId) { + if (!authStore.isAuthenticated) { channels.value = []; error.value = null; loadedWorkspaceId.value = null; return; } - if (!force && loadedWorkspaceId.value === currentWorkspaceId) { + if (!force && loadedWorkspaceId.value === currentScopeKey) { return; } @@ -46,12 +48,14 @@ export const useChannelsStore = defineStore('channels', () => { try { const response = await client.get('/api/channels', { params: { - workspaceId: currentWorkspaceId, + workspaceId: currentWorkspaceId ?? undefined, }, }); - channels.value = response.data ?? []; - loadedWorkspaceId.value = currentWorkspaceId; + channels.value = (response.data ?? []).filter(channel => + workspaceStore.isWorkspaceVisible(channel.workspaceId) + ); + loadedWorkspaceId.value = currentScopeKey; } catch (fetchError) { console.error('Failed to fetch channels:', fetchError); channels.value = []; @@ -101,9 +105,9 @@ export const useChannelsStore = defineStore('channels', () => { } watch( - () => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId], - async ([isAuthenticated, workspaceId]) => { - if (!isAuthenticated || !workspaceId) { + () => [authStore.isAuthenticated, workspaceStore.workspaceScopeKey], + async ([isAuthenticated]) => { + if (!isAuthenticated) { channels.value = []; error.value = null; loadedWorkspaceId.value = null; diff --git a/frontend/src/features/channels/views/ChannelsView.vue b/frontend/src/features/channels/views/ChannelsView.vue index ebe8c07..499a408 100644 --- a/frontend/src/features/channels/views/ChannelsView.vue +++ b/frontend/src/features/channels/views/ChannelsView.vue @@ -47,10 +47,12 @@ .filter(channel => channel.network) .map(channel => { const metrics = buildMetrics(channel.name); + const workspace = workspaceStore.workspaces.find(candidate => candidate.id === channel.workspaceId); return { ...channel, ...metrics, + workspaceName: workspace?.name ?? t('nav.noWorkspace'), }; }) ); @@ -215,7 +217,7 @@ >
{{ channel.name }} - {{ workspaceStore.activeWorkspace?.name || t('nav.noWorkspace') }} + {{ channel.workspaceName }}
diff --git a/frontend/src/features/clients/stores/clientsStore.js b/frontend/src/features/clients/stores/clientsStore.js index 7dd09f8..ce9e9a5 100644 --- a/frontend/src/features/clients/stores/clientsStore.js +++ b/frontend/src/features/clients/stores/clientsStore.js @@ -25,7 +25,7 @@ export const useClientsStore = defineStore('clients', () => { }); async function fetchClients() { - if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) { + if (!authStore.isAuthenticated) { clients.value = []; error.value = null; return; @@ -37,11 +37,13 @@ export const useClientsStore = defineStore('clients', () => { try { const response = await client.get('/api/clients', { params: { - workspaceId: workspaceStore.activeWorkspaceId, + workspaceId: workspaceStore.activeWorkspaceId ?? undefined, }, }); - clients.value = response.data ?? []; + clients.value = (response.data ?? []).filter(candidate => + workspaceStore.isWorkspaceVisible(candidate.workspaceId) + ); } catch (fetchError) { console.error('Failed to fetch clients:', fetchError); clients.value = []; @@ -153,9 +155,9 @@ export const useClientsStore = defineStore('clients', () => { } watch( - () => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId], - async ([isAuthenticated, workspaceId]) => { - if (!isAuthenticated || !workspaceId) { + () => [authStore.isAuthenticated, workspaceStore.workspaceScopeKey], + async ([isAuthenticated]) => { + if (!isAuthenticated) { clients.value = []; error.value = null; return; diff --git a/frontend/src/features/content/stores/contentItemDetailStore.js b/frontend/src/features/content/stores/contentItemDetailStore.js index 7b985cc..f83ac94 100644 --- a/frontend/src/features/content/stores/contentItemDetailStore.js +++ b/frontend/src/features/content/stores/contentItemDetailStore.js @@ -24,6 +24,10 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = status: false, }); + function currentItemWorkspaceId() { + return item.value?.workspaceId ?? workspaceStore.activeWorkspaceId; + } + function reset() { item.value = null; revisions.value = []; @@ -54,7 +58,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = client.get('/api/approvals', { params: { contentItemId } }), client.get('/api/notifications', { params: { - workspaceId: workspaceStore.activeWorkspaceId, + workspaceId: workspaceStore.activeWorkspaceId ?? undefined, contentItemId, }, }), @@ -97,7 +101,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = const response = await client.post('/api/assets/google-drive', { ...payload, contentItemId, - workspaceId: workspaceStore.activeWorkspaceId, + workspaceId: currentItemWorkspaceId(), }); if (response.data) { assets.value = [...assets.value, response.data]; @@ -131,7 +135,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = const response = await client.post('/api/comments', { ...payload, contentItemId, - workspaceId: workspaceStore.activeWorkspaceId, + workspaceId: currentItemWorkspaceId(), }); if (response.data) { comments.value = [...comments.value, response.data]; @@ -202,7 +206,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = async function fetchNotifications(contentItemId) { const response = await client.get('/api/notifications', { params: { - workspaceId: workspaceStore.activeWorkspaceId, + workspaceId: currentItemWorkspaceId() ?? undefined, contentItemId, }, }); diff --git a/frontend/src/features/content/stores/contentItemsStore.js b/frontend/src/features/content/stores/contentItemsStore.js index d94c6a5..4274292 100644 --- a/frontend/src/features/content/stores/contentItemsStore.js +++ b/frontend/src/features/content/stores/contentItemsStore.js @@ -20,7 +20,7 @@ export const useContentItemsStore = defineStore('content-items', () => { ); async function fetchContentItems(filters = {}) { - if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) { + if (!authStore.isAuthenticated) { items.value = []; error.value = null; return; @@ -32,13 +32,15 @@ export const useContentItemsStore = defineStore('content-items', () => { try { const response = await client.get('/api/content-items', { params: { - workspaceId: workspaceStore.activeWorkspaceId, + workspaceId: workspaceStore.activeWorkspaceId ?? undefined, clientId: filters.clientId, campaignId: filters.campaignId, }, }); - items.value = response.data ?? []; + items.value = (response.data ?? []).filter(item => + workspaceStore.isWorkspaceVisible(item.workspaceId) + ); } catch (fetchError) { console.error('Failed to fetch content items:', fetchError); items.value = []; @@ -86,9 +88,9 @@ export const useContentItemsStore = defineStore('content-items', () => { } watch( - () => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId], - async ([isAuthenticated, workspaceId]) => { - if (!isAuthenticated || !workspaceId) { + () => [authStore.isAuthenticated, workspaceStore.workspaceScopeKey], + async ([isAuthenticated]) => { + if (!isAuthenticated) { items.value = []; error.value = null; return; diff --git a/frontend/src/features/workspaces/stores/workspaceStore.js b/frontend/src/features/workspaces/stores/workspaceStore.js index 654ad29..f52d506 100644 --- a/frontend/src/features/workspaces/stores/workspaceStore.js +++ b/frontend/src/features/workspaces/stores/workspaceStore.js @@ -11,6 +11,7 @@ export const useWorkspaceStore = defineStore('workspace', () => { const workspaces = ref([]); const activeWorkspaceId = ref(null); + const visibleWorkspaceIds = ref([]); const isLoading = ref(false); const isCreating = ref(false); const isUpdating = ref(false); @@ -25,11 +26,43 @@ export const useWorkspaceStore = defineStore('workspace', () => { const activeWorkspace = computed(() => workspaces.value.find(workspace => workspace.id === activeWorkspaceId.value) ?? null ); + const isAllWorkspacesSelected = computed(() => + activeWorkspaceId.value === null && workspaces.value.length > 1 + ); + const visibleWorkspaceCount = computed(() => + activeWorkspaceId.value ? 1 : visibleWorkspaceIds.value.length + ); + const areAllWorkspacesVisible = computed(() => + activeWorkspaceId.value === null && + workspaces.value.length > 1 && + visibleWorkspaceIds.value.length === workspaces.value.length + ); + const visibleWorkspaceIdSet = computed(() => new Set(visibleWorkspaceIds.value)); + const workspaceScopeKey = computed(() => + activeWorkspaceId.value ?? visibleWorkspaceIds.value.slice().sort().join(',') + ); + + function allWorkspaceIds() { + return workspaces.value.map(workspace => workspace.id); + } + + function normalizeVisibleWorkspaces() { + const workspaceIds = allWorkspaceIds(); + const knownWorkspaceIds = new Set(workspaceIds); + const nextVisibleWorkspaceIds = visibleWorkspaceIds.value.filter(workspaceId => + knownWorkspaceIds.has(workspaceId) + ); + + visibleWorkspaceIds.value = nextVisibleWorkspaceIds.length > 0 + ? nextVisibleWorkspaceIds + : workspaceIds; + } async function fetchWorkspaces() { if (!authStore.isAuthenticated) { workspaces.value = []; activeWorkspaceId.value = null; + visibleWorkspaceIds.value = []; error.value = null; return; } @@ -40,9 +73,10 @@ export const useWorkspaceStore = defineStore('workspace', () => { try { const response = await client.get('/api/workspaces'); workspaces.value = response.data ?? []; + normalizeVisibleWorkspaces(); if (!workspaces.value.some(workspace => workspace.id === activeWorkspaceId.value)) { - activeWorkspaceId.value = workspaces.value[0]?.id ?? null; + activeWorkspaceId.value = workspaces.value.length > 1 ? null : workspaces.value[0]?.id ?? null; } organizationStore.setSelectedOrganizationFromWorkspace(activeWorkspace.value); @@ -75,6 +109,7 @@ export const useWorkspaceStore = defineStore('workspace', () => { workspaces.value = [...workspaces.value, response.data] .sort((left, right) => left.name.localeCompare(right.name)); activeWorkspaceId.value = response.data.id; + visibleWorkspaceIds.value = [response.data.id]; try { await client.post('/api/clients', { @@ -172,10 +207,62 @@ export const useWorkspaceStore = defineStore('workspace', () => { if (workspaces.value.some(workspace => workspace.id === workspaceId)) { activeWorkspaceId.value = workspaceId; + visibleWorkspaceIds.value = [workspaceId]; organizationStore.setSelectedOrganizationFromWorkspace(activeWorkspace.value); } } + function setAllWorkspaces() { + if (workspaces.value.length > 1) { + activeWorkspaceId.value = null; + visibleWorkspaceIds.value = allWorkspaceIds(); + } + } + + function isWorkspaceVisible(workspaceId) { + if (activeWorkspaceId.value) { + return workspaceId === activeWorkspaceId.value; + } + + if (visibleWorkspaceIds.value.length === 0) { + return true; + } + + return visibleWorkspaceIdSet.value.has(workspaceId); + } + + function toggleWorkspaceVisibility(workspaceId) { + if (!workspaces.value.some(workspace => workspace.id === workspaceId)) { + return; + } + + const wasFocusedOnSingleWorkspace = Boolean(activeWorkspaceId.value); + activeWorkspaceId.value = null; + const visibleIds = new Set( + wasFocusedOnSingleWorkspace || visibleWorkspaceIds.value.length === 0 + ? allWorkspaceIds() + : visibleWorkspaceIds.value + ); + + if (visibleIds.has(workspaceId)) { + visibleIds.delete(workspaceId); + } else { + visibleIds.add(workspaceId); + } + + if (visibleIds.size === 0) { + visibleIds.add(workspaceId); + } + + const nextVisibleWorkspaceIds = allWorkspaceIds().filter(id => visibleIds.has(id)); + if (nextVisibleWorkspaceIds.length === 1) { + setActiveWorkspace(nextVisibleWorkspaceIds[0]); + return; + } + + visibleWorkspaceIds.value = nextVisibleWorkspaceIds; + } + async function fetchInvites(workspaceId = activeWorkspaceId.value) { if (!authStore.isAuthenticated || !workspaceId) { invitesByWorkspace.value = {}; @@ -257,6 +344,7 @@ export const useWorkspaceStore = defineStore('workspace', () => { if (!isAuthenticated) { workspaces.value = []; activeWorkspaceId.value = null; + visibleWorkspaceIds.value = []; error.value = null; return; } @@ -270,6 +358,11 @@ export const useWorkspaceStore = defineStore('workspace', () => { workspaces, activeWorkspaceId, activeWorkspace, + visibleWorkspaceIds, + isAllWorkspacesSelected, + visibleWorkspaceCount, + areAllWorkspacesVisible, + workspaceScopeKey, isLoading, isCreating, isUpdating, @@ -288,5 +381,8 @@ export const useWorkspaceStore = defineStore('workspace', () => { fetchMembers, inviteMember, setActiveWorkspace, + setAllWorkspaces, + isWorkspaceVisible, + toggleWorkspaceVisibility, }; }); diff --git a/frontend/src/layouts/main/WorkspaceSelector.vue b/frontend/src/layouts/main/WorkspaceSelector.vue index 5f2fcb0..7131c6a 100644 --- a/frontend/src/layouts/main/WorkspaceSelector.vue +++ b/frontend/src/layouts/main/WorkspaceSelector.vue @@ -12,6 +12,8 @@ import { mdiChevronDown, mdiCogOutline, + mdiEyeOffOutline, + mdiEyeOutline, mdiPlus, mdiSwapHorizontal, } from '@mdi/js'; @@ -37,6 +39,7 @@ ); }); const canSwitchWorkspaces = computed(() => visibleWorkspaces.value.length > 1); + const canSelectAllWorkspaces = computed(() => visibleWorkspaces.value.length > 1); const canSwitchOrganizations = computed(() => organizationStore.organizations.length > 1); const switchableOrganizations = computed(() => organizationStore.organizations.filter( @@ -51,8 +54,19 @@ const canOpenWorkspaceMenu = computed(() => canSwitchWorkspaces.value || canSwitchOrganizations.value || canManageWorkspaces.value || Boolean(activeOrganization.value) ); - const activeWorkspaceName = computed(() => - workspaceStore.activeWorkspace?.name || t('nav.noWorkspace') + const activeWorkspaceName = computed(() => { + if (workspaceStore.areAllWorkspacesVisible) { + return t('workspaceSelector.allWorkspaces'); + } + + if (workspaceStore.isAllWorkspacesSelected) { + return t('workspaceSelector.multipleWorkspaces'); + } + + return workspaceStore.activeWorkspace?.name || t('nav.noWorkspace'); + }); + const activeWorkspaceLogoUrl = computed(() => + workspaceStore.isAllWorkspacesSelected ? null : workspaceStore.activeWorkspace?.logoUrl ); const activeOrganizationName = computed(() => activeOrganization.value?.name || t('workspaceSelector.noOrganization') @@ -73,13 +87,30 @@ isOrganizationListOpen.value = false; } + function chooseAllWorkspaces() { + workspaceStore.setAllWorkspaces(); + isWorkspaceMenuOpen.value = false; + isOrganizationListOpen.value = false; + } + + function toggleWorkspaceVisibility(workspaceId) { + workspaceStore.toggleWorkspaceVisibility(workspaceId); + } + function chooseOrganization(organizationId) { organizationStore.setSelectedOrganization(organizationId); const nextWorkspace = workspaceStore.workspaces.find( workspace => workspace.organizationId === organizationId ); - workspaceStore.setActiveWorkspace(nextWorkspace?.id ?? null); + const organizationWorkspaceCount = workspaceStore.workspaces.filter( + workspace => workspace.organizationId === organizationId + ).length; + if (organizationWorkspaceCount > 1) { + workspaceStore.setAllWorkspaces(); + } else { + workspaceStore.setActiveWorkspace(nextWorkspace?.id ?? null); + } isOrganizationListOpen.value = false; } @@ -137,7 +168,7 @@ > {{ activeWorkspaceName }} @@ -153,11 +184,31 @@ v-if="isWorkspaceMenuOpen" class="user-menu" > + +
+ +