mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +00:00
feat(web): place workspace controls in sidebar
This commit is contained in:
@@ -1915,6 +1915,10 @@ export default function HomeSidebar({
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
||||||
|
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Navigation items grouped by section */}
|
{/* Navigation items grouped by section */}
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<SidebarContent ref={navigationContentRef} className="min-h-0 pb-8">
|
<SidebarContent ref={navigationContentRef} className="min-h-0 pb-8">
|
||||||
@@ -1970,11 +1974,6 @@ export default function HomeSidebar({
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
{currentWorkspace?.workspace.source === 'cloud_projection' && (
|
|
||||||
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
|
||||||
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Models entry */}
|
{/* Models entry */}
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Copy, Loader2, Trash2, UserPlus, Users } from 'lucide-react';
|
import {
|
||||||
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -27,12 +34,11 @@ import type {
|
|||||||
WorkspaceMembership,
|
WorkspaceMembership,
|
||||||
WorkspaceRole,
|
WorkspaceRole,
|
||||||
} from '@/app/infra/entities/workspace';
|
} from '@/app/infra/entities/workspace';
|
||||||
import { backendClient } from '@/app/infra/http';
|
import { backendClient, systemInfo } from '@/app/infra/http';
|
||||||
import {
|
import {
|
||||||
PanelBody,
|
PanelBody,
|
||||||
PanelToolbar,
|
PanelToolbar,
|
||||||
} from '@/app/home/components/settings-dialog/panel-layout';
|
} from '@/app/home/components/settings-dialog/panel-layout';
|
||||||
import WorkspaceSwitcher from './WorkspaceSwitcher';
|
|
||||||
|
|
||||||
interface WorkspaceSettingsPanelProps {
|
interface WorkspaceSettingsPanelProps {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -77,6 +83,13 @@ export default function WorkspaceSettingsPanel({
|
|||||||
!isCloudProjection && permissions.has('member.remove');
|
!isCloudProjection && permissions.has('member.remove');
|
||||||
const canTransferOwner =
|
const canTransferOwner =
|
||||||
!isCloudProjection && permissions.has('owner.transfer');
|
!isCloudProjection && permissions.has('owner.transfer');
|
||||||
|
const canManageCloudMembers =
|
||||||
|
isCloudProjection &&
|
||||||
|
(workspaceInfo?.membership.role === 'owner' ||
|
||||||
|
workspaceInfo?.membership.role === 'admin');
|
||||||
|
const cloudMembersURL = workspaceInfo
|
||||||
|
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}#workspace-members`
|
||||||
|
: '';
|
||||||
|
|
||||||
const loadWorkspace = useCallback(async () => {
|
const loadWorkspace = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -203,12 +216,20 @@ export default function WorkspaceSettingsPanel({
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<WorkspaceSwitcher className="ml-auto" />
|
|
||||||
{workspaceInfo && (
|
{workspaceInfo && (
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
|
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{canManageCloudMembers && (
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<a href={cloudMembersURL} target="_blank" rel="noopener noreferrer">
|
||||||
|
<UserPlus />
|
||||||
|
{t('workspace.inviteMember')}
|
||||||
|
<ExternalLink className="size-3.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</PanelToolbar>
|
</PanelToolbar>
|
||||||
|
|
||||||
<PanelBody className="space-y-6">
|
<PanelBody className="space-y-6">
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function WorkspaceSwitcher({
|
|||||||
const workspaces = useWorkspaceBootstrap();
|
const workspaces = useWorkspaceBootstrap();
|
||||||
const isCloud = currentWorkspace?.workspace.source === 'cloud_projection';
|
const isCloud = currentWorkspace?.workspace.source === 'cloud_projection';
|
||||||
|
|
||||||
if (!currentWorkspace || (!isCloud && workspaces.length <= 1)) return null;
|
if (!currentWorkspace) return null;
|
||||||
|
|
||||||
const cloudPortalUrl = `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(currentWorkspace.workspace.uuid)}`;
|
const cloudPortalUrl = `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(currentWorkspace.workspace.uuid)}`;
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
PluginInstallProgressDialog,
|
PluginInstallProgressDialog,
|
||||||
} from '@/app/home/plugins/components/plugin-install-task';
|
} from '@/app/home/plugins/components/plugin-install-task';
|
||||||
import { setDocumentTitle } from '@/hooks/useDocumentTitle';
|
import { setDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
import WorkspaceSwitcher from '@/app/home/components/workspace-settings/WorkspaceSwitcher';
|
|
||||||
|
|
||||||
// Routes that belong to the "Extensions" section
|
// Routes that belong to the "Extensions" section
|
||||||
const EXTENSIONS_ROUTES = [
|
const EXTENSIONS_ROUTES = [
|
||||||
@@ -265,7 +264,6 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
<WorkspaceSwitcher className="ml-auto w-44" />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const webRoot = path.resolve(currentDirectory, '../..');
|
||||||
|
|
||||||
|
function readSource(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeLayoutSource = readSource('src/app/home/layout.tsx');
|
||||||
|
const homeSidebarSource = readSource(
|
||||||
|
'src/app/home/components/home-sidebar/HomeSidebar.tsx',
|
||||||
|
);
|
||||||
|
const workspaceSettingsPanelSource = readSource(
|
||||||
|
'src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx',
|
||||||
|
);
|
||||||
|
const workspaceSwitcherSource = readSource(
|
||||||
|
'src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('renders WorkspaceSwitcher only from HomeSidebar', () => {
|
||||||
|
assert.doesNotMatch(homeLayoutSource, /<WorkspaceSwitcher\b/);
|
||||||
|
assert.doesNotMatch(workspaceSettingsPanelSource, /<WorkspaceSwitcher\b/);
|
||||||
|
assert.equal(homeSidebarSource.match(/<WorkspaceSwitcher\b/g)?.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('places WorkspaceSwitcher between the sidebar header and Home navigation', () => {
|
||||||
|
const headerEnd = homeSidebarSource.indexOf('</SidebarHeader>');
|
||||||
|
const switcher = homeSidebarSource.indexOf('<WorkspaceSwitcher');
|
||||||
|
const contentStart = homeSidebarSource.indexOf('<SidebarContent');
|
||||||
|
const homeGroup = homeSidebarSource.indexOf(
|
||||||
|
"<SidebarGroupLabel>{t('sidebar.home')}</SidebarGroupLabel>",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.notEqual(headerEnd, -1);
|
||||||
|
assert.notEqual(switcher, -1);
|
||||||
|
assert.notEqual(contentStart, -1);
|
||||||
|
assert.notEqual(homeGroup, -1);
|
||||||
|
assert.ok(
|
||||||
|
headerEnd < switcher,
|
||||||
|
'WorkspaceSwitcher must follow SidebarHeader',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
switcher < contentStart && switcher < homeGroup,
|
||||||
|
'WorkspaceSwitcher must precede SidebarContent and the Home group',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows WorkspaceSwitcher for a current Cloud or OSS workspace even when it is the only workspace', () => {
|
||||||
|
assert.match(
|
||||||
|
workspaceSwitcherSource,
|
||||||
|
/if \(!currentWorkspace\) return null;/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(workspaceSwitcherSource, /workspaces\.length\s*<=\s*1/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
homeSidebarSource,
|
||||||
|
/currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'[\s\S]{0,200}<WorkspaceSwitcher/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('links Cloud workspace managers to the Space member invitation surface', () => {
|
||||||
|
assert.match(workspaceSettingsPanelSource, /systemInfo\.cloud_service_url/);
|
||||||
|
assert.match(workspaceSettingsPanelSource, /#workspace-members/);
|
||||||
|
assert.match(workspaceSettingsPanelSource, /workspace\.inviteMember/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user