Add multi-workspace selector scope

This commit is contained in:
2026-05-05 13:20:44 -04:00
parent 78a7517de7
commit 0d4188b64e
11 changed files with 270 additions and 37 deletions

View File

@@ -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`

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 @@
>
<div class="channel-header">
<strong>{{ channel.name }}</strong>
<span>{{ workspaceStore.activeWorkspace?.name || t('nav.noWorkspace') }}</span>
<span>{{ channel.workspaceName }}</span>
</div>
<div class="channel-metrics">

View File

@@ -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;

View File

@@ -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,
},
});

View File

@@ -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;

View File

@@ -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,
};
});

View File

@@ -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 @@
>
<AppAvatar
:name="activeWorkspaceName"
:src="workspaceStore.activeWorkspace?.logoUrl"
:src="activeWorkspaceLogoUrl"
size="sm"
/>
<span class="label workspace-trigger-label">{{ activeWorkspaceName }}</span>
@@ -153,11 +184,31 @@
v-if="isWorkspaceMenuOpen"
class="user-menu"
>
<button
v-if="canSelectAllWorkspaces"
class="user-menu-item all-workspaces-item"
:class="{ 'user-menu-item-active': workspaceStore.isAllWorkspacesSelected }"
type="button"
@click="chooseAllWorkspaces"
>
<AppAvatar
:name="t('workspaceSelector.allWorkspaces')"
size="sm"
/>
<span class="user-menu-item-copy">
<span>{{ t('workspaceSelector.allWorkspaces') }}</span>
<small>{{ t('workspaceSelector.allWorkspacesDescription') }}</small>
</span>
</button>
<div
v-for="workspace in visibleWorkspaces"
:key="workspace.id"
class="workspace-menu-row"
:class="{ 'user-menu-item-active': workspace.id === workspaceStore.activeWorkspaceId }"
:class="{
'user-menu-item-active': workspace.id === workspaceStore.activeWorkspaceId,
'workspace-menu-row-muted': workspaceStore.isAllWorkspacesSelected && !workspaceStore.isWorkspaceVisible(workspace.id),
}"
>
<button
class="user-menu-item workspace-menu-select"
@@ -175,6 +226,16 @@
</span>
</button>
<button
v-if="canSelectAllWorkspaces"
class="workspace-visibility-button"
type="button"
:aria-label="workspaceStore.isWorkspaceVisible(workspace.id) ? t('workspaceSelector.hideWorkspace') : t('workspaceSelector.showWorkspace')"
@click.stop="toggleWorkspaceVisibility(workspace.id)"
>
<v-icon :icon="workspaceStore.isWorkspaceVisible(workspace.id) ? mdiEyeOutline : mdiEyeOffOutline" />
</button>
<button
v-if="canManageWorkspaces"
class="workspace-settings-button"
@@ -326,6 +387,16 @@
color: #172033;
}
.workspace-menu-row-muted {
opacity: 0.58;
}
.all-workspaces-item {
@apply mb-1 border;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(23, 32, 51, 0.03);
}
.workspace-menu-select {
@apply min-w-0 flex-1;
}
@@ -339,11 +410,18 @@
color: #526178;
}
.workspace-visibility-button {
@apply flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full transition-colors;
color: #526178;
}
.workspace-visibility-button:hover,
.workspace-settings-button:hover {
background: rgba(23, 32, 51, 0.1);
color: #172033;
}
.workspace-visibility-button :deep(.v-icon),
.workspace-settings-button :deep(.v-icon) {
font-size: 1rem;
}

View File

@@ -360,10 +360,15 @@
"saving": "Saving..."
},
"workspaceSelector": {
"allWorkspaces": "All Workspaces",
"allWorkspacesDescription": "Show every workspace",
"createAction": "Add workspace",
"hideWorkspace": "Hide workspace",
"multipleWorkspaces": "Multiple Workspaces",
"organizationLabel": "Organization",
"organizationSettings": "Organization settings",
"noOrganization": "No organization",
"showWorkspace": "Show workspace",
"workspaceSettings": "Workspace settings"
},
"workspaceCreate": {

View File

@@ -360,10 +360,15 @@
"saving": "Enregistrement..."
},
"workspaceSelector": {
"allWorkspaces": "Tous les espaces",
"allWorkspacesDescription": "Afficher tous les espaces",
"createAction": "Ajouter un espace",
"hideWorkspace": "Masquer l'espace",
"multipleWorkspaces": "Plusieurs espaces",
"organizationLabel": "Organisation",
"organizationSettings": "Parametres de l'organisation",
"noOrganization": "Aucune organisation",
"showWorkspace": "Afficher l'espace",
"workspaceSettings": "Parametres de l'espace"
},
"workspaceCreate": {