feat: add delete workspace feature #96

This commit is contained in:
moonrailgun 2024-09-14 22:09:45 +08:00
parent 943f7f594b
commit 2b9a14c969
17 changed files with 323 additions and 81 deletions

View File

@ -1,6 +1,12 @@
import React, { useState } from 'react';
import { cn } from '@/utils/style';
import { setUserInfo, useUserInfo } from '@/store/user';
import {
changeUserCurrentWorkspace,
setUserInfo,
useCurrentWorkspace,
useCurrentWorkspaceSafe,
useUserInfo,
} from '@/store/user';
import { LuPlusCircle } from 'react-icons/lu';
import { useTranslation } from '@i18next-toolkit/react';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
@ -30,6 +36,7 @@ import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { trpc } from '@/api/trpc';
import { showErrorToast } from '@/utils/error';
import { first, upperCase } from 'lodash-es';
import { Empty } from 'antd';
interface WorkspaceSwitcherProps {
isCollapsed: boolean;
@ -41,6 +48,7 @@ export const WorkspaceSwitcher: React.FC<WorkspaceSwitcherProps> = React.memo(
const [open, setOpen] = React.useState(false);
const [showNewWorkspaceDialog, setShowNewWorkspaceDialog] = useState(false);
const [newWorkspaceName, setNewWorkspaceName] = useState('');
const currentWorkspace = useCurrentWorkspaceSafe();
const createWorkspaceMutation = trpc.workspace.create.useMutation({
onSuccess: (userInfo) => {
setUserInfo(userInfo);
@ -56,7 +64,7 @@ export const WorkspaceSwitcher: React.FC<WorkspaceSwitcherProps> = React.memo(
async (workspace: { id: string; name: string }) => {
setOpen(false);
if (userInfo?.currentWorkspace.id === workspace.id) {
if (userInfo?.currentWorkspaceId === workspace.id) {
return;
}
@ -64,6 +72,7 @@ export const WorkspaceSwitcher: React.FC<WorkspaceSwitcherProps> = React.memo(
await switchWorkspaceMutation.mutateAsync({
workspaceId: workspace.id,
});
changeUserCurrentWorkspace(workspace.id);
} catch (err) {
showErrorToast(err);
}
@ -88,8 +97,6 @@ export const WorkspaceSwitcher: React.FC<WorkspaceSwitcherProps> = React.memo(
return null;
}
const currentWorkspace = userInfo.currentWorkspace;
return (
<Dialog
open={showNewWorkspaceDialog}
@ -106,6 +113,8 @@ export const WorkspaceSwitcher: React.FC<WorkspaceSwitcherProps> = React.memo(
props.isCollapsed && 'h-9 w-9 items-center justify-center p-0'
)}
>
{currentWorkspace ? (
<>
<Avatar
className={cn('h-5 w-5', props.isCollapsed ? '' : 'mr-2')}
>
@ -127,6 +136,10 @@ export const WorkspaceSwitcher: React.FC<WorkspaceSwitcherProps> = React.memo(
>
{currentWorkspace.name}
</span>
</>
) : (
<span>{t('Select Workspace')}</span>
)}
<CaretSortIcon
className={cn(
@ -141,6 +154,15 @@ export const WorkspaceSwitcher: React.FC<WorkspaceSwitcherProps> = React.memo(
<CommandList>
<CommandEmpty>{t('No workspace found.')}</CommandEmpty>
<CommandGroup key="workspace" heading={t('Workspace')}>
{userInfo.workspaces.length === 0 && (
<Empty
imageStyle={{ width: 80, height: 80, margin: 'auto' }}
description={t(
'Not any workspace has been found, please create first'
)}
/>
)}
{userInfo.workspaces.map(({ workspace }) => (
<CommandItem
key={workspace.id}

View File

@ -1,12 +1,12 @@
import { useUserInfo } from '@/store/user';
import { useCurrentWorkspaceSafe } from '@/store/user';
import React from 'react';
import { Helmet } from 'react-helmet';
export const LayoutHeader: React.FC = React.memo(() => {
const userInfo = useUserInfo();
const currentWorkspace = useCurrentWorkspaceSafe();
let title = 'Tianji - Insight into everything';
if (userInfo) {
title = userInfo.currentWorkspace.name + ' | ' + title;
if (currentWorkspace) {
title = currentWorkspace.name + ' | ' + title;
}
return (

View File

@ -18,6 +18,7 @@ import { useEvent } from '@/hooks/useEvent';
import { useSettingsStore } from '@/store/settings';
import {
setUserInfo,
useCurrentWorkspace,
useCurrentWorkspaceId,
useUserInfo,
useUserStore,
@ -41,6 +42,7 @@ export const UserConfig: React.FC<UserConfigProps> = React.memo((props) => {
const navigate = useNavigate();
const colorScheme = useSettingsStore((state) => state.colorScheme);
const workspaceId = useCurrentWorkspaceId();
const currentWorkspace = useCurrentWorkspace();
const workspaces = useUserStore((state) => {
const userInfo = state.info;
if (userInfo) {
@ -48,7 +50,7 @@ export const UserConfig: React.FC<UserConfigProps> = React.memo((props) => {
id: w.workspace.id,
name: w.workspace.name,
role: w.role,
current: userInfo.currentWorkspace?.id === w.workspace.id,
current: currentWorkspace.id === w.workspace.id,
}));
}

View File

@ -1,4 +1,4 @@
import { Button, Form, Input, message, Popconfirm } from 'antd';
import { Form, Input, message } from 'antd';
import React from 'react';
import { deleteWorkspaceWebsite } from '../../api/model/website';
import { useRequest } from '../../hooks/useRequest';
@ -18,6 +18,8 @@ import { useTranslation } from '@i18next-toolkit/react';
import { useNavigate } from '@tanstack/react-router';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { AlertConfirm } from '../AlertConfirm';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader } from '../ui/card';
export const WebsiteConfig: React.FC<{ websiteId: string }> = React.memo(
(props) => {
@ -51,6 +53,14 @@ export const WebsiteConfig: React.FC<{ websiteId: string }> = React.memo(
workspaceId,
websiteId,
});
trpcUtils.website.all.refetch({ workspaceId });
navigate({
to: '/website/$websiteId',
params: {
websiteId,
},
});
}
);
@ -135,21 +145,28 @@ export const WebsiteConfig: React.FC<{ websiteId: string }> = React.memo(
</Form.Item>
<Form.Item>
<Button size="large" htmlType="submit">
{t('Save')}
</Button>
<Button type="submit">{t('Save')}</Button>
</Form.Item>
</Form>
</TabsContent>
<TabsContent value="data">
<Card>
<CardHeader className="text-lg font-bold">
{t('Danger Zone')}
</CardHeader>
<CardContent>
<div>
<AlertConfirm
title={t('Delete Website')}
onConfirm={() => handleDeleteWebsite()}
>
<Button type="primary" danger={true}>
<Button variant="destructive">
{t('Delete Website')}
</Button>
</AlertConfirm>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>

View File

@ -13,6 +13,7 @@
import { Route as rootRoute } from './routes/__root'
import { Route as WebsiteImport } from './routes/website'
import { Route as TelemetryImport } from './routes/telemetry'
import { Route as SwitchWorkspaceImport } from './routes/switchWorkspace'
import { Route as SurveyImport } from './routes/survey'
import { Route as SettingsImport } from './routes/settings'
import { Route as ServerImport } from './routes/server'
@ -58,6 +59,11 @@ const TelemetryRoute = TelemetryImport.update({
getParentRoute: () => rootRoute,
} as any)
const SwitchWorkspaceRoute = SwitchWorkspaceImport.update({
path: '/switchWorkspace',
getParentRoute: () => rootRoute,
} as any)
const SurveyRoute = SurveyImport.update({
path: '/survey',
getParentRoute: () => rootRoute,
@ -258,6 +264,10 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SurveyImport
parentRoute: typeof rootRoute
}
'/switchWorkspace': {
preLoaderRoute: typeof SwitchWorkspaceImport
parentRoute: typeof rootRoute
}
'/telemetry': {
preLoaderRoute: typeof TelemetryImport
parentRoute: typeof rootRoute
@ -391,6 +401,7 @@ export const routeTree = rootRoute.addChildren([
SurveySurveyIdEditRoute,
SurveySurveyIdIndexRoute,
]),
SwitchWorkspaceRoute,
TelemetryRoute.addChildren([TelemetryTelemetryIdRoute, TelemetryAddRoute]),
WebsiteRoute.addChildren([
WebsiteAddRoute,

View File

@ -37,6 +37,8 @@ import { Button } from '@/components/ui/button';
import { useEventWithLoading } from '@/hooks/useEvent';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { AlertConfirm } from '@/components/AlertConfirm';
import { ROLES } from '@tianji/shared';
export const Route = createFileRoute('/settings/workspace')({
beforeLoad: routeAuthBeforeLoad,
@ -54,7 +56,7 @@ const columnHelper = createColumnHelper<MemberInfo>();
function PageComponent() {
const { t } = useTranslation();
const { id: workspaceId, name } = useCurrentWorkspace();
const { id: workspaceId, name, role } = useCurrentWorkspace();
const { data: members = [], refetch: refetchMembers } =
trpc.workspace.members.useQuery({
workspaceId,
@ -69,6 +71,10 @@ function PageComponent() {
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const deleteWorkspaceMutation = trpc.workspace.delete.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const [handleInvite, isLoading] = useEventWithLoading(
async (values: InviteFormValues) => {
@ -173,6 +179,41 @@ function PageComponent() {
<DataTable columns={columns} data={members} />
</CardContent>
</Card>
{role === ROLES.owner && (
<Card>
<CardHeader className="text-lg font-bold">
{t('Danger Zone')}
</CardHeader>
<CardContent>
<div>
<AlertConfirm
title={'Confirm to delete this workspace'}
description={t(
'All content in this workspace will be destory and can not recover.'
)}
onConfirm={async () => {
await deleteWorkspaceMutation.mutateAsync({
workspaceId,
});
setTimeout(() => {
window.location.reload();
}, 1000);
}}
>
<Button
type="button"
loading={deleteWorkspaceMutation.isLoading}
variant="destructive"
>
{t('Delete Workspace')}
</Button>
</AlertConfirm>
</div>
</CardContent>
</Card>
)}
</div>
</ScrollArea>
</CommonWrapper>

View File

@ -0,0 +1,79 @@
import {
createFileRoute,
redirect,
useNavigate,
useSearch,
} from '@tanstack/react-router';
import { useTranslation } from '@i18next-toolkit/react';
import { z } from 'zod';
import { useCurrentWorkspaceSafe, type UserLoginInfo } from '../store/user';
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from '@/components/ui/card';
import { WorkspaceSwitcher } from '@/components/WorkspaceSwitcher';
import { Button } from '@/components/ui/button';
import { useEvent } from '@/hooks/useEvent';
export const Route = createFileRoute('/switchWorkspace')({
validateSearch: z.object({
// redirect: z.string().catch('/'),
redirect: z.string().optional(),
}),
beforeLoad: ({ context }) => {
const userInfo: UserLoginInfo | undefined = (context as any).userInfo;
if (
userInfo &&
userInfo.currentWorkspaceId &&
userInfo.workspaces.some(
(w) => w.workspace.id === userInfo.currentWorkspaceId
)
) {
throw redirect({
to: '/',
});
}
},
component: PageComponent,
});
function PageComponent() {
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspaceSafe();
const search = Route.useSearch();
const navigate = useNavigate();
const handleEnter = useEvent(() => {
navigate({
to: search.redirect ?? '/',
});
});
return (
<div className="flex h-full w-full items-center justify-center">
<Card className="min-w-[320px] bg-zinc-50 dark:bg-zinc-900">
<CardHeader>
<div className="text-center">
<img className="m-auto h-24 w-24" src="/icon.svg" />
</div>
</CardHeader>
<CardContent className="text-center">
<div className="mb-2 text-lg font-bold">{t('Select Workspace')}</div>
<WorkspaceSwitcher isCollapsed={false} />
</CardContent>
{currentWorkspace && (
<CardFooter className="justify-end">
<Button size="sm" onClick={handleEnter}>
{t('Enter')}
</Button>
</CardFooter>
)}
</Card>
</div>
);
}

View File

@ -1,32 +1,29 @@
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
import { createSocketIOClient } from '../api/socketio';
import { AppRouterOutput } from '../api/trpc';
type UserLoginInfo = NonNullable<AppRouterOutput['user']['info']>;
export type UserLoginInfo = NonNullable<AppRouterOutput['user']['info']>;
interface UserState {
info: UserLoginInfo | null;
}
export const useUserStore = create<UserState>(() => ({
export const useUserStore = createWithEqualityFn<UserState>(
() => ({
info: null,
}));
}),
shallow
);
export function setUserInfo(info: UserLoginInfo) {
if (!info.currentWorkspace && info.workspaces[0]) {
// Make sure currentWorkspace existed
info.currentWorkspace = {
...info.workspaces[0].workspace,
};
}
useUserStore.setState({
info,
});
// create socketio after login
if (info.currentWorkspace) {
createSocketIOClient(info.currentWorkspace.id);
if (info.currentWorkspaceId) {
createSocketIOClient(info.currentWorkspaceId);
}
}
@ -38,11 +35,57 @@ export function useIsLogined() {
return !!useUserInfo();
}
export function useCurrentWorkspace() {
const currentWorkspace = useUserStore(
(state) => state.info?.currentWorkspace
export function changeUserCurrentWorkspace(currentWorkspaceId: string) {
const currentUserInfo = useUserStore.getState().info;
if (!currentUserInfo) {
return;
}
useUserStore.setState({
info: {
...currentUserInfo,
currentWorkspaceId,
},
});
createSocketIOClient(currentWorkspaceId);
}
export function useCurrentWorkspaceSafe() {
const currentWorkspace = useUserStore((state) => {
if (!state.info) {
return null;
}
const currentWorkspaceId = state.info.currentWorkspaceId;
if (!currentWorkspaceId) {
return null;
}
const currentWorkspace = state.info?.workspaces.find(
(w) => w.workspace.id === currentWorkspaceId
);
if (!currentWorkspace) {
return null;
}
return {
id: currentWorkspace.workspace.id,
name: currentWorkspace.workspace.name,
role: currentWorkspace.role,
};
});
return currentWorkspace;
}
/**
* Direct return current workspace info
* NOTICE: its will throw error if no workspace id
*/
export function useCurrentWorkspace() {
const currentWorkspace = useCurrentWorkspaceSafe();
if (!currentWorkspace) {
throw new Error('No Workspace Id');
}
@ -50,9 +93,13 @@ export function useCurrentWorkspace() {
return currentWorkspace;
}
/**
* Direct return current workspace id
* NOTICE: its will throw error if no workspace id
*/
export function useCurrentWorkspaceId() {
const currentWorkspaceId = useUserStore(
(state) => state.info?.currentWorkspace?.id
(state) => state.info?.currentWorkspaceId
);
if (!currentWorkspaceId) {

View File

@ -1,10 +1,12 @@
import { UserLoginInfo } from '@/store/user';
import { FileBaseRouteOptions, redirect } from '@tanstack/react-router';
export const routeAuthBeforeLoad: FileBaseRouteOptions['beforeLoad'] = ({
context,
location,
}) => {
if (!(context as any).userInfo) {
const userInfo: UserLoginInfo | undefined = (context as any).userInfo;
if (!userInfo) {
throw redirect({
to: '/login',
search: {
@ -12,4 +14,18 @@ export const routeAuthBeforeLoad: FileBaseRouteOptions['beforeLoad'] = ({
},
});
}
if (
!userInfo.currentWorkspaceId ||
userInfo.workspaces.every(
(w) => w.workspace.id !== userInfo.currentWorkspaceId
)
) {
throw redirect({
to: '/switchWorkspace',
search: {
redirect: location.href,
},
});
}
};

View File

@ -31,7 +31,7 @@ export const userInfoSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
deletedAt: z.date().nullable(),
currentWorkspace: workspaceSchema,
currentWorkspaceId: z.string().nullable(),
workspaces: z.array(
z.object({
role: z.string(),

View File

@ -33,12 +33,7 @@ export const createUserSelect = {
createdAt: true,
updatedAt: true,
deletedAt: true,
currentWorkspace: {
select: {
id: true,
name: true,
},
},
currentWorkspaceId: true,
workspaces: {
select: {
role: true,

View File

@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "User" DROP CONSTRAINT "User_currentWorkspaceId_fkey";
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "currentWorkspaceId" DROP NOT NULL;

View File

@ -29,9 +29,7 @@ model User {
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
deletedAt DateTime? @db.Timestamptz(6)
currentWorkspaceId String @db.VarChar(30)
currentWorkspace Workspace @relation(fields: [currentWorkspaceId], references: [id])
currentWorkspaceId String? @db.VarChar(30)
accounts Account[]
sessions Session[]
@ -97,7 +95,6 @@ model Workspace {
telemetryList Telemetry[]
// for user currentWorkspace
selectedUsers User[] // user list who select this workspace, not use in most of case
workspaceDailyUsage WorkspaceDailyUsage[]
workspaceAuditLog WorkspaceAuditLog[]
surveys Survey[]

View File

@ -1,6 +1,6 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
import { CompleteWorkspace, RelatedWorkspaceModelSchema, CompleteAccount, RelatedAccountModelSchema, CompleteSession, RelatedSessionModelSchema, CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema } from "./index.js"
import { CompleteAccount, RelatedAccountModelSchema, CompleteSession, RelatedSessionModelSchema, CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema } from "./index.js"
export const UserModelSchema = z.object({
id: z.string(),
@ -14,11 +14,10 @@ export const UserModelSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
deletedAt: z.date().nullish(),
currentWorkspaceId: z.string(),
currentWorkspaceId: z.string().nullish(),
})
export interface CompleteUser extends z.infer<typeof UserModelSchema> {
currentWorkspace: CompleteWorkspace
accounts: CompleteAccount[]
sessions: CompleteSession[]
workspaces: CompleteWorkspacesOnUsers[]
@ -30,7 +29,6 @@ export interface CompleteUser extends z.infer<typeof UserModelSchema> {
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const RelatedUserModelSchema: z.ZodSchema<CompleteUser> = z.lazy(() => UserModelSchema.extend({
currentWorkspace: RelatedWorkspaceModelSchema,
accounts: RelatedAccountModelSchema.array(),
sessions: RelatedSessionModelSchema.array(),
workspaces: RelatedWorkspacesOnUsersModelSchema.array(),

View File

@ -1,6 +1,6 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
import { CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema, CompleteWebsite, RelatedWebsiteModelSchema, CompleteNotification, RelatedNotificationModelSchema, CompleteMonitor, RelatedMonitorModelSchema, CompleteMonitorStatusPage, RelatedMonitorStatusPageModelSchema, CompleteTelemetry, RelatedTelemetryModelSchema, CompleteUser, RelatedUserModelSchema, CompleteWorkspaceDailyUsage, RelatedWorkspaceDailyUsageModelSchema, CompleteWorkspaceAuditLog, RelatedWorkspaceAuditLogModelSchema, CompleteSurvey, RelatedSurveyModelSchema, CompleteFeedChannel, RelatedFeedChannelModelSchema } from "./index.js"
import { CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema, CompleteWebsite, RelatedWebsiteModelSchema, CompleteNotification, RelatedNotificationModelSchema, CompleteMonitor, RelatedMonitorModelSchema, CompleteMonitorStatusPage, RelatedMonitorStatusPageModelSchema, CompleteTelemetry, RelatedTelemetryModelSchema, CompleteWorkspaceDailyUsage, RelatedWorkspaceDailyUsageModelSchema, CompleteWorkspaceAuditLog, RelatedWorkspaceAuditLogModelSchema, CompleteSurvey, RelatedSurveyModelSchema, CompleteFeedChannel, RelatedFeedChannelModelSchema } from "./index.js"
// Helper schema for JSON fields
type Literal = boolean | number | string
@ -31,7 +31,6 @@ export interface CompleteWorkspace extends z.infer<typeof WorkspaceModelSchema>
monitors: CompleteMonitor[]
monitorStatusPages: CompleteMonitorStatusPage[]
telemetryList: CompleteTelemetry[]
selectedUsers: CompleteUser[]
workspaceDailyUsage: CompleteWorkspaceDailyUsage[]
workspaceAuditLog: CompleteWorkspaceAuditLog[]
surveys: CompleteSurvey[]
@ -50,7 +49,6 @@ export const RelatedWorkspaceModelSchema: z.ZodSchema<CompleteWorkspace> = z.laz
monitors: RelatedMonitorModelSchema.array(),
monitorStatusPages: RelatedMonitorStatusPageModelSchema.array(),
telemetryList: RelatedTelemetryModelSchema.array(),
selectedUsers: RelatedUserModelSchema.array(),
workspaceDailyUsage: RelatedWorkspaceDailyUsageModelSchema.array(),
workspaceAuditLog: RelatedWorkspaceAuditLogModelSchema.array(),
surveys: RelatedSurveyModelSchema.array(),

View File

@ -138,7 +138,7 @@ export const userRouter = router({
info: protectProedure
.input(z.void())
.output(userInfoSchema.nullable())
.query(async ({ input, ctx }) => {
.query(async ({ ctx }) => {
return getUserInfo(ctx.user.id);
}),
});

View File

@ -23,6 +23,7 @@ import {
leaveWorkspace,
} from '../../model/user.js';
import { WorkspacesOnUsersModelSchema } from '../../prisma/zod/workspacesonusers.js';
import { monitorManager } from '../../model/monitor/index.js';
export const workspaceRouter = router({
create: protectProedure
@ -104,7 +105,7 @@ export const workspaceRouter = router({
id: workspaceId,
users: {
some: {
userId,
userId, // make sure is member of this workspace
},
},
},
@ -143,6 +144,19 @@ export const workspaceRouter = router({
const { workspaceId } = input;
const userId = ctx.user.id;
const monitors = await prisma.monitor.findMany({
where: {
workspaceId,
},
select: {
id: true,
},
});
await Promise.all(
monitors.map((m) => monitorManager.delete(workspaceId, m.id))
);
await prisma.workspace.delete({
where: {
id: workspaceId,