feat: add create workspace and switch workspace

This commit is contained in:
moonrailgun 2024-08-18 01:12:47 +08:00
parent ebd1e5eb66
commit fac0838d8c
9 changed files with 444 additions and 50 deletions

View File

@ -12,6 +12,11 @@ const useSocketStore = create<{
}));
export function createSocketIOClient(workspaceId: string) {
const prev = useSocketStore.getState().socket;
if (prev) {
prev.disconnect();
}
const socket = io(`/${workspaceId}`, {
transports: ['websocket'],
reconnectionDelayMax: 10000,

View File

@ -0,0 +1,11 @@
import { isDev } from '@/utils/env';
import React, { PropsWithChildren } from 'react';
export const DevContainer: React.FC<PropsWithChildren> = React.memo((props) => {
if (isDev) {
return <>{props.children}</>;
}
return null;
});
DevContainer.displayName = 'DevContainer';

View File

@ -1,14 +1,35 @@
import React from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import React, { useState } from 'react';
import { cn } from '@/utils/style';
import { useUserInfo } from '@/store/user';
import { RiRocket2Fill } from 'react-icons/ri';
import { setUserInfo, useUserInfo } from '@/store/user';
import { LuPlusCircle } from 'react-icons/lu';
import { useTranslation } from '@i18next-toolkit/react';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { Button } from './ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
CommandSeparator,
} from './ui/command';
import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from './ui/dialog';
import { Label } from './ui/label';
import { Input } from './ui/input';
import { useEvent, useEventWithLoading } from '@/hooks/useEvent';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { trpc } from '@/api/trpc';
import { showErrorToast } from '@/utils/error';
import { first, upperCase } from 'lodash-es';
interface WorkspaceSwitcherProps {
isCollapsed: boolean;
@ -16,41 +37,241 @@ interface WorkspaceSwitcherProps {
export const WorkspaceSwitcher: React.FC<WorkspaceSwitcherProps> = React.memo(
(props) => {
const userInfo = useUserInfo();
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const [showNewWorkspaceDialog, setShowNewWorkspaceDialog] = useState(false);
const [newWorkspaceName, setNewWorkspaceName] = useState('');
const createWorkspaceMutation = trpc.workspace.create.useMutation({
onSuccess: (userInfo) => {
setUserInfo(userInfo);
},
});
const switchWorkspaceMutation = trpc.workspace.switch.useMutation({
onSuccess: (userInfo) => {
setUserInfo(userInfo);
},
});
const handleSwitchWorkspace = useEvent(
async (workspace: { id: string; name: string }) => {
setOpen(false);
if (userInfo?.currentWorkspace.id === workspace.id) {
return;
}
try {
await switchWorkspaceMutation.mutateAsync({
workspaceId: workspace.id,
});
} catch (err) {
showErrorToast(err);
}
}
);
const [handleCreateNewWorkspace, isCreateLoading] = useEventWithLoading(
async () => {
try {
await createWorkspaceMutation.mutateAsync({
name: newWorkspaceName,
});
setShowNewWorkspaceDialog(false);
} catch (err) {
showErrorToast(err);
}
}
);
if (!userInfo) {
return null;
}
return (
<Select value={userInfo.currentWorkspace.id}>
<SelectTrigger
className={cn(
'flex items-center gap-2 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0',
props.isCollapsed &&
'flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden'
)}
aria-label="Select workspace"
>
<SelectValue placeholder="Select workspace">
<RiRocket2Fill />
const currentWorkspace = userInfo.currentWorkspace;
<span className={cn('ml-2', props.isCollapsed && 'hidden')}>
{userInfo.currentWorkspace.name}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{userInfo.workspaces.map((w) => (
<SelectItem key={w.workspace.id} value={w.workspace.id}>
<div className="flex items-center gap-3 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-foreground">
<RiRocket2Fill />
{w.workspace.name}
return (
<Dialog
open={showNewWorkspaceDialog}
onOpenChange={setShowNewWorkspaceDialog}
>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
'w-full justify-between',
props.isCollapsed &&
'flex h-9 w-9 items-center justify-center p-0'
)}
>
<Avatar
className={cn('h-5 w-5', props.isCollapsed ? '' : 'mr-2')}
>
<AvatarImage
src={`https://avatar.vercel.sh/${currentWorkspace.name}.png`}
alt={currentWorkspace.name}
className="grayscale"
/>
<AvatarFallback>
{upperCase(first(currentWorkspace.name))}
</AvatarFallback>
</Avatar>
<span className={cn(props.isCollapsed && 'hidden')}>
{currentWorkspace.name}
</span>
<CaretSortIcon
className={cn(
'ml-auto h-4 w-4 shrink-0 opacity-50',
props.isCollapsed && 'hidden'
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandList>
<CommandEmpty>{t('No workspace found.')}</CommandEmpty>
<CommandGroup key="workspace" heading={t('Workspace')}>
{userInfo.workspaces.map(({ workspace }) => (
<CommandItem
key={workspace.id}
onSelect={() => {
handleSwitchWorkspace(workspace);
}}
className="text-sm"
>
<Avatar className="mr-2 h-5 w-5">
<AvatarImage
src={`https://avatar.vercel.sh/${workspace.name}.png`}
alt={workspace.name}
className="grayscale"
/>
<AvatarFallback>
{upperCase(first(workspace.name))}
</AvatarFallback>
</Avatar>
{workspace.name}
<CheckIcon
className={cn(
'ml-auto h-4 w-4',
currentWorkspace.id === workspace.id
? 'opacity-100'
: 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
<CommandSeparator />
<CommandList>
<CommandGroup key="create">
<DialogTrigger asChild>
<CommandItem
aria-selected="false"
onSelect={() => {
setOpen(false);
setShowNewWorkspaceDialog(true);
}}
>
<LuPlusCircle className="mr-2" size={20} />
{t('Create Workspace')}
</CommandItem>
</DialogTrigger>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Create Workspace')}</DialogTitle>
<DialogDescription>
{t('Create a new workspace to cooperate with team members.')}
</DialogDescription>
</DialogHeader>
<div>
<div className="space-y-4 py-2 pb-4">
<div className="space-y-2">
<Label>{t('Workspace Name')}</Label>
<Input
value={newWorkspaceName}
onChange={(e) => setNewWorkspaceName(e.target.value)}
/>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewWorkspaceDialog(false)}
>
{t('Cancel')}
</Button>
<Button
loading={isCreateLoading}
onClick={handleCreateNewWorkspace}
>
{t('Create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
// return (
// <Select value={userInfo.currentWorkspace.id}>
// <SelectTrigger
// className={cn(
// 'flex items-center gap-2 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0',
// props.isCollapsed &&
// 'flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden'
// )}
// aria-label="Select workspace"
// >
// <SelectValue placeholder="Select workspace">
// <RiRocket2Fill />
// <span className={cn('ml-2', props.isCollapsed && 'hidden')}>
// {userInfo.currentWorkspace.name}
// </span>
// </SelectValue>
// </SelectTrigger>
// <SelectContent>
// {userInfo.workspaces.map((w) => (
// <SelectItem key={w.workspace.id} value={w.workspace.id}>
// <div className="[&_svg]:text-foreground flex items-center gap-3 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0">
// <RiRocket2Fill />
// {w.workspace.name}
// </div>
// </SelectItem>
// ))}
// <SelectSeparator />
// <SelectItem
// value="create"
// onClick={() => console.log('aa')}
// onSelect={() => console.log('bbb')}
// >
// <div className="[&_svg]:text-foreground flex items-center gap-3 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0">
// <LuPlus />
// {t('Create Workspace')}
// </div>
// </SelectItem>
// </SelectContent>
// </Select>
// );
}
);
WorkspaceSwitcher.displayName = 'WorkspaceSwitcher';

View File

@ -16,13 +16,19 @@ import {
} from '@/components/ui/dropdown-menu';
import { useEvent } from '@/hooks/useEvent';
import { useSettingsStore } from '@/store/settings';
import { useCurrentWorkspaceId, useUserInfo, useUserStore } from '@/store/user';
import {
setUserInfo,
useCurrentWorkspaceId,
useUserInfo,
useUserStore,
} from '@/store/user';
import { languages } from '@/utils/constants';
import { useTranslation, setLanguage } from '@i18next-toolkit/react';
import { useNavigate } from '@tanstack/react-router';
import { version } from '@/utils/env';
import React from 'react';
import { LuMoreVertical } from 'react-icons/lu';
import { trpc } from '@/api/trpc';
interface UserConfigProps {
isCollapsed: boolean;
@ -46,6 +52,11 @@ export const UserConfig: React.FC<UserConfigProps> = React.memo((props) => {
return [];
});
const switchWorkspaceMutation = trpc.workspace.switch.useMutation({
onSuccess: (userInfo) => {
setUserInfo(userInfo);
},
});
const handleChangeColorSchema = useEvent((colorScheme) => {
useSettingsStore.setState({
@ -125,7 +136,12 @@ export const UserConfig: React.FC<UserConfigProps> = React.memo((props) => {
<DropdownMenuRadioItem
key={workspace.id}
value={workspace.id}
disabled={true}
disabled={workspace.id === workspaceId}
onSelect={() =>
switchWorkspaceMutation.mutateAsync({
workspaceId: workspace.id,
})
}
>
{workspace.name}
</DropdownMenuRadioItem>

View File

@ -54,6 +54,7 @@ const AvatarFallback = React.forwardRef<
'bg-muted flex h-full w-full items-center justify-center rounded-full',
className
)}
delayMs={5000}
{...props}
/>
));

View File

@ -13,7 +13,7 @@ const Command = React.forwardRef<
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-white text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50',
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
{...props}
@ -27,7 +27,7 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-zinc-500 dark:[&_[cmdk-group-heading]]:text-zinc-400 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
@ -44,7 +44,7 @@ const CommandInput = React.forwardRef<
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-zinc-500 disabled:cursor-not-allowed disabled:opacity-50 dark:placeholder:text-zinc-400',
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
@ -87,7 +87,7 @@ const CommandGroup = React.forwardRef<
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-zinc-950 dark:text-zinc-50 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-zinc-500 dark:[&_[cmdk-group-heading]]:text-zinc-400',
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className
)}
{...props}
@ -102,7 +102,7 @@ const CommandSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-zinc-200 dark:bg-zinc-800', className)}
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
));
@ -115,7 +115,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-zinc-100 aria-selected:text-zinc-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:aria-selected:bg-zinc-800 dark:aria-selected:text-zinc-50',
'data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
className
)}
{...props}
@ -131,7 +131,7 @@ const CommandShortcut = ({
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-zinc-500 dark:text-zinc-400',
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}

27
src/client/utils/error.ts Normal file
View File

@ -0,0 +1,27 @@
import { TRPCClientError } from '@trpc/client';
import { toast } from 'sonner';
/**
* Show common error toast with auto process error object
*/
export function showErrorToast(err: any) {
console.error(err);
if (err instanceof TRPCClientError) {
try {
const json = JSON.parse(err.message);
toast.error(json[0].message);
} catch {
toast.error(err.message);
}
return;
}
if (err instanceof Error) {
toast.error(err.message);
return;
}
toast.error(String(err));
}

View File

@ -21,7 +21,7 @@ export async function getUserCount(): Promise<number> {
return count;
}
const createUserSelect = {
export const createUserSelect = {
id: true,
username: true,
nickname: true,

View File

@ -1,5 +1,6 @@
import {
OpenApiMetaInfo,
protectProedure,
publicProcedure,
router,
workspaceOwnerProcedure,
@ -7,13 +8,119 @@ import {
} from '../trpc.js';
import { z } from 'zod';
import { prisma } from '../../model/_client.js';
import { workspaceDashboardLayoutSchema } from '../../model/_schema/index.js';
import {
userInfoSchema,
workspaceDashboardLayoutSchema,
} from '../../model/_schema/index.js';
import { Prisma } from '@prisma/client';
import { OPENAPI_TAG } from '../../utils/const.js';
import { OpenApiMeta } from 'trpc-openapi';
import { getServerCount } from '../../model/serverStatus.js';
import { ROLES, slugRegex } from '@tianji/shared';
import { createUserSelect } from '../../model/user.js';
export const workspaceRouter = router({
create: protectProedure
.meta(
buildWorkspaceOpenapi({
method: 'POST',
path: '/create',
})
)
.input(
z.object({
name: z
.string()
.max(60)
.min(4)
.regex(slugRegex, { message: 'no a valid name' }),
})
)
.output(userInfoSchema)
.mutation(async ({ input, ctx }) => {
const { name } = input;
const userId = ctx.user.id;
const existed = await prisma.workspace.findFirst({
where: {
name,
},
});
if (existed) {
throw new Error('This workspace has been existed');
}
const userInfo = await prisma.$transaction(async (p) => {
const newWorkspace = await p.workspace.create({
data: {
name,
},
});
return await p.user.update({
data: {
currentWorkspaceId: newWorkspace.id,
workspaces: {
create: {
workspaceId: newWorkspace.id,
role: ROLES.owner,
},
},
},
where: {
id: userId,
},
select: createUserSelect,
});
});
return userInfo;
}),
switch: protectProedure
.meta(
buildWorkspaceOpenapi({
method: 'POST',
path: '/switch',
})
)
.input(
z.object({
workspaceId: z.string(),
})
)
.output(userInfoSchema)
.mutation(async ({ input, ctx }) => {
const userId = ctx.user.id;
const { workspaceId } = input;
const targetWorkspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
users: {
some: {
userId,
},
},
},
});
if (!targetWorkspace) {
throw new Error('Target Workspace not found!');
}
const userInfo = await prisma.user.update({
where: {
id: userId,
},
data: {
currentWorkspaceId: targetWorkspace.id,
},
select: createUserSelect,
});
return userInfo;
}),
getUserWorkspaceRole: publicProcedure
.input(
z.object({
@ -40,7 +147,7 @@ export const workspaceRouter = router({
.meta(
buildWorkspaceOpenapi({
method: 'GET',
path: '/getServiceCount',
path: '/{workspaceId}/getServiceCount',
})
)
.output(
@ -103,6 +210,9 @@ export const workspaceRouter = router({
feed,
};
}),
/**
* @deprecated
*/
updateDashboardOrder: workspaceOwnerProcedure
.input(
z.object({
@ -121,6 +231,9 @@ export const workspaceRouter = router({
},
});
}),
/**
* @deprecated
*/
saveDashboardLayout: workspaceOwnerProcedure
.input(
z.object({
@ -147,7 +260,7 @@ function buildWorkspaceOpenapi(meta: OpenApiMetaInfo): OpenApiMeta {
tags: [OPENAPI_TAG.WORKSPACE],
protect: true,
...meta,
path: `/workspace/{workspaceId}${meta.path}`,
path: `/workspace/${meta.path}`,
},
};
}