feat: survey basic fe framework and add new form

This commit is contained in:
moonrailgun 2024-04-28 21:13:53 +08:00
parent 27642625ac
commit 010fd00be3
12 changed files with 535 additions and 4 deletions

View File

@ -11,7 +11,7 @@ export const TipIcon: React.FC<TipIconProps> = React.memo((props) => {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger type="button">
<LuHelpCircle className={className} /> <LuHelpCircle className={className} />
</TooltipTrigger> </TooltipTrigger>

View File

@ -0,0 +1,255 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useTranslation } from '@i18next-toolkit/react';
import { Button } from '@/components/ui/button';
import { useEvent, useEventWithLoading } from '@/hooks/useEvent';
import { useCurrentWorkspaceId } from '@/store/user';
import { defaultErrorHandler, trpc } from '@/api/trpc';
import { Card, CardContent, CardFooter } from '@/components/ui/card';
import { CommonWrapper } from '@/components/CommonWrapper';
import { routeAuthBeforeLoad } from '@/utils/route';
import { z } from 'zod';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { generateRandomString } from '@/utils/common';
import { LuArrowDown, LuArrowUp, LuMinus, LuPlus } from 'react-icons/lu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import React, { useState } from 'react';
import { TipIcon } from '../TipIcon';
import { cn } from '@/utils/style';
import { Switch } from '../ui/switch';
const addFormSchema = z.object({
name: z.string(),
payload: z.object({
items: z.array(
z.object({
label: z.string(),
name: z.string(),
type: z.enum(['text', 'select', 'email']),
options: z.array(z.string()).optional(),
})
),
}),
});
export type SurveyEditFormValues = z.infer<typeof addFormSchema>;
function generateDefaultItem() {
return {
label: 'New Field',
name: 'field-' + generateRandomString(4),
type: 'text' as const,
};
}
interface SurveyEditFormProps {
defaultValues?: SurveyEditFormValues;
onSubmit: (values: SurveyEditFormValues) => Promise<void>;
}
export const SurveyEditForm: React.FC<SurveyEditFormProps> = React.memo(
(props) => {
const { t } = useTranslation();
const [advancedMode, setAdvancedMode] = useState(false);
const form = useForm<SurveyEditFormValues>({
resolver: zodResolver(addFormSchema),
defaultValues: props.defaultValues ?? {
name: 'New Survey',
payload: {
items: [generateDefaultItem()],
},
},
});
const [handleSubmit, isLoading] = useEventWithLoading(
async (values: SurveyEditFormValues) => {
await props.onSubmit(values);
form.reset();
}
);
const { fields, append, swap, insert, remove } = useFieldArray({
control: form.control,
name: 'payload.items',
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-8">
<Card>
<CardContent className="pt-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('Survey Name')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('Survey Name to Display')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="border-muted mt-2 rounded-lg border p-4">
<h2 className="mb-2 font-bold leading-6">{t('Form Info')}</h2>
<div className="flex items-center justify-end gap-2">
<Switch
checked={advancedMode}
onCheckedChange={(checked) => setAdvancedMode(checked)}
/>
<div className="text-sm">{t('Advanced Mode')}</div>
</div>
{fields.map((field, i) => (
<div key={field.id} className="mb-2 flex gap-1">
<FormItem>
<FormLabel>{t('Display Label')}</FormLabel>
<FormControl>
<Input {...form.register(`payload.items.${i}.label`)} />
</FormControl>
<FormMessage />
</FormItem>
{advancedMode && (
<FormItem>
<FormLabel>
{t('Name')}
<TipIcon
className="ml-1"
content={t('Use for storage')}
/>
</FormLabel>
<FormControl>
<Input
{...form.register(`payload.items.${i}.name`)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
<FormItem>
<FormLabel>{t('Type')}</FormLabel>
<FormControl>
<Select
defaultValue="text"
onValueChange={(val) =>
form.setValue(`payload.items.${i}.type`, val as any)
}
>
<SelectTrigger
className="w-[100px]"
{...form.register(`payload.items.${i}.type`)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">{t('Text')}</SelectItem>
<SelectItem value="email">{t('Email')}</SelectItem>
{/* <SelectItem value="select">
{t('Select')}
</SelectItem> */}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
{/* actions */}
<div className="grid min-w-10 grid-flow-col grid-cols-2 grid-rows-2 gap-0.5 self-end">
<Button
type="button"
variant="dashed"
size="icon"
className="h-5 w-5 text-xs"
Icon={LuMinus}
onClick={() => {
remove(i);
}}
/>
<Button
type="button"
variant="dashed"
size="icon"
className="h-5 w-5 text-xs"
Icon={LuPlus}
onClick={() => {
insert(i + 1, generateDefaultItem());
}}
/>
<Button
type="button"
variant="dashed"
size="icon"
className={cn(
'h-5 w-5 text-xs',
i === 0 && 'opacity-0'
)}
Icon={LuArrowUp}
onClick={() => {
swap(i, i - 1);
}}
/>
<Button
type="button"
variant="dashed"
size="icon"
className={cn(
'h-5 w-5 text-xs',
i === fields.length - 1 && 'opacity-0'
)}
Icon={LuArrowDown}
onClick={() => {
swap(i, i + 1);
}}
/>
</div>
</div>
))}
<div className="mt-2 flex justify-end">
<Button
variant="dashed"
type="button"
Icon={LuPlus}
size="icon"
onClick={() => append(generateDefaultItem())}
/>
</div>
</div>
</CardContent>
<CardFooter>
<Button type="submit" loading={isLoading}>
{t('Create')}
</Button>
</CardFooter>
</Card>
</form>
</Form>
);
}
);
SurveyEditForm.displayName = 'SurveyEditForm';

View File

@ -4,6 +4,7 @@ import {
LuFilePieChart, LuFilePieChart,
LuMonitorDot, LuMonitorDot,
LuServer, LuServer,
LuTableProperties,
LuWifi, LuWifi,
} from 'react-icons/lu'; } from 'react-icons/lu';
import { TooltipProvider } from '@/components/ui/tooltip'; import { TooltipProvider } from '@/components/ui/tooltip';
@ -97,6 +98,12 @@ export const DesktopLayout: React.FC<LayoutProps> = React.memo((props) => {
icon: LuFilePieChart, icon: LuFilePieChart,
to: '/page', to: '/page',
}, },
{
title: t('Survey'),
label: String(serviceCount?.survey ?? ''),
icon: LuTableProperties,
to: '/survey',
},
]} ]}
/> />
<Separator /> <Separator />

View File

@ -13,6 +13,7 @@
import { Route as rootRoute } from './routes/__root' import { Route as rootRoute } from './routes/__root'
import { Route as WebsiteImport } from './routes/website' import { Route as WebsiteImport } from './routes/website'
import { Route as TelemetryImport } from './routes/telemetry' import { Route as TelemetryImport } from './routes/telemetry'
import { Route as SurveyImport } from './routes/survey'
import { Route as SettingsImport } from './routes/settings' import { Route as SettingsImport } from './routes/settings'
import { Route as ServerImport } from './routes/server' import { Route as ServerImport } from './routes/server'
import { Route as RegisterImport } from './routes/register' import { Route as RegisterImport } from './routes/register'
@ -25,6 +26,8 @@ import { Route as WebsiteOverviewImport } from './routes/website/overview'
import { Route as WebsiteAddImport } from './routes/website/add' import { Route as WebsiteAddImport } from './routes/website/add'
import { Route as TelemetryAddImport } from './routes/telemetry/add' import { Route as TelemetryAddImport } from './routes/telemetry/add'
import { Route as TelemetryTelemetryIdImport } from './routes/telemetry/$telemetryId' import { Route as TelemetryTelemetryIdImport } from './routes/telemetry/$telemetryId'
import { Route as SurveyAddImport } from './routes/survey/add'
import { Route as SurveySurveyIdImport } from './routes/survey/$surveyId'
import { Route as StatusSlugImport } from './routes/status/$slug' import { Route as StatusSlugImport } from './routes/status/$slug'
import { Route as SettingsUsageImport } from './routes/settings/usage' import { Route as SettingsUsageImport } from './routes/settings/usage'
import { Route as SettingsProfileImport } from './routes/settings/profile' import { Route as SettingsProfileImport } from './routes/settings/profile'
@ -50,6 +53,11 @@ const TelemetryRoute = TelemetryImport.update({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const SurveyRoute = SurveyImport.update({
path: '/survey',
getParentRoute: () => rootRoute,
} as any)
const SettingsRoute = SettingsImport.update({ const SettingsRoute = SettingsImport.update({
path: '/settings', path: '/settings',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
@ -110,6 +118,16 @@ const TelemetryTelemetryIdRoute = TelemetryTelemetryIdImport.update({
getParentRoute: () => TelemetryRoute, getParentRoute: () => TelemetryRoute,
} as any) } as any)
const SurveyAddRoute = SurveyAddImport.update({
path: '/add',
getParentRoute: () => SurveyRoute,
} as any)
const SurveySurveyIdRoute = SurveySurveyIdImport.update({
path: '/$surveyId',
getParentRoute: () => SurveyRoute,
} as any)
const StatusSlugRoute = StatusSlugImport.update({ const StatusSlugRoute = StatusSlugImport.update({
path: '/status/$slug', path: '/status/$slug',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
@ -206,6 +224,10 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsImport preLoaderRoute: typeof SettingsImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/survey': {
preLoaderRoute: typeof SurveyImport
parentRoute: typeof rootRoute
}
'/telemetry': { '/telemetry': {
preLoaderRoute: typeof TelemetryImport preLoaderRoute: typeof TelemetryImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
@ -246,6 +268,14 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StatusSlugImport preLoaderRoute: typeof StatusSlugImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/survey/$surveyId': {
preLoaderRoute: typeof SurveySurveyIdImport
parentRoute: typeof SurveyImport
}
'/survey/add': {
preLoaderRoute: typeof SurveyAddImport
parentRoute: typeof SurveyImport
}
'/telemetry/$telemetryId': { '/telemetry/$telemetryId': {
preLoaderRoute: typeof TelemetryTelemetryIdImport preLoaderRoute: typeof TelemetryTelemetryIdImport
parentRoute: typeof TelemetryImport parentRoute: typeof TelemetryImport
@ -301,6 +331,7 @@ export const routeTree = rootRoute.addChildren([
SettingsProfileRoute, SettingsProfileRoute,
SettingsUsageRoute, SettingsUsageRoute,
]), ]),
SurveyRoute.addChildren([SurveySurveyIdRoute, SurveyAddRoute]),
TelemetryRoute.addChildren([TelemetryTelemetryIdRoute, TelemetryAddRoute]), TelemetryRoute.addChildren([TelemetryTelemetryIdRoute, TelemetryAddRoute]),
WebsiteRoute.addChildren([ WebsiteRoute.addChildren([
WebsiteAddRoute, WebsiteAddRoute,

View File

@ -0,0 +1,89 @@
import { trpc } from '@/api/trpc';
import { CommonHeader } from '@/components/CommonHeader';
import { CommonList } from '@/components/CommonList';
import { CommonWrapper } from '@/components/CommonWrapper';
import { Button } from '@/components/ui/button';
import { useDataReady } from '@/hooks/useDataReady';
import { useEvent } from '@/hooks/useEvent';
import { LayoutV2 } from '@/pages/LayoutV2';
import { useCurrentWorkspaceId } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route';
import { Trans, useTranslation } from '@i18next-toolkit/react';
import {
createFileRoute,
useNavigate,
useRouterState,
} from '@tanstack/react-router';
import { LuPlus } from 'react-icons/lu';
export const Route = createFileRoute('/survey')({
beforeLoad: routeAuthBeforeLoad,
component: PageComponent,
});
function PageComponent() {
const workspaceId = useCurrentWorkspaceId();
const { t } = useTranslation();
const { data = [] } = trpc.survey.all.useQuery({
workspaceId,
});
const { data: allResultCount = {} } = trpc.survey.allResultCount.useQuery({
workspaceId,
});
const navigate = useNavigate();
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
const items = data.map((item) => ({
id: item.id,
title: item.name,
number: allResultCount[item.id] ?? 0,
href: `/survey/${item.id}`,
}));
useDataReady(
() => data.length > 0,
() => {
if (pathname === Route.fullPath) {
navigate({
to: '/survey/$surveyId',
params: {
surveyId: data[0].id,
},
});
}
}
);
const handleClickAdd = useEvent(() => {
navigate({
to: '/survey/add',
});
});
return (
<LayoutV2
list={
<CommonWrapper
header={
<CommonHeader
title={t('Survey')}
actions={
<Button
variant="outline"
Icon={LuPlus}
onClick={handleClickAdd}
>
{t('Add')}
</Button>
}
/>
}
>
<CommonList hasSearch={true} items={items} />
</CommonWrapper>
}
/>
);
}

View File

@ -0,0 +1,54 @@
import { defaultErrorHandler, defaultSuccessHandler, trpc } from '@/api/trpc';
import { CommonHeader } from '@/components/CommonHeader';
import { CommonWrapper } from '@/components/CommonWrapper';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { useGlobalRangeDate } from '@/hooks/useGlobalRangeDate';
import { useCurrentWorkspaceId } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route';
import { useTranslation } from '@i18next-toolkit/react';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useEvent } from '@/hooks/useEvent';
export const Route = createFileRoute('/survey/$surveyId')({
beforeLoad: routeAuthBeforeLoad,
component: PageComponent,
});
function PageComponent() {
const { surveyId } = Route.useParams<{ surveyId: string }>();
const workspaceId = useCurrentWorkspaceId();
const { t } = useTranslation();
const { data: info } = trpc.survey.get.useQuery({
workspaceId,
surveyId,
});
const { data: count } = trpc.survey.count.useQuery({
workspaceId,
surveyId,
});
const deleteMutation = trpc.survey.delete.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const trpcUtils = trpc.useUtils();
const navigate = useNavigate();
const handleDelete = useEvent(async () => {
await deleteMutation.mutateAsync({ workspaceId, surveyId });
trpcUtils.survey.all.refetch();
navigate({
to: '/survey',
replace: true,
});
});
return (
<CommonWrapper header={<CommonHeader title={info?.name ?? ''} />}>
<ScrollArea className="h-full overflow-hidden p-4">
<ScrollBar orientation="horizontal" />
{/* */}
</ScrollArea>
</CommonWrapper>
);
}

View File

@ -0,0 +1,53 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useTranslation } from '@i18next-toolkit/react';
import { useEvent } from '@/hooks/useEvent';
import { useCurrentWorkspaceId } from '@/store/user';
import { defaultErrorHandler, trpc } from '@/api/trpc';
import { CommonWrapper } from '@/components/CommonWrapper';
import { routeAuthBeforeLoad } from '@/utils/route';
import {
SurveyEditForm,
SurveyEditFormValues,
} from '@/components/survey/SurveyEditForm';
export const Route = createFileRoute('/survey/add')({
beforeLoad: routeAuthBeforeLoad,
component: PageComponent,
});
function PageComponent() {
const { t } = useTranslation();
const workspaceId = useCurrentWorkspaceId();
const createMutation = trpc.survey.create.useMutation({
onError: defaultErrorHandler,
});
const utils = trpc.useUtils();
const navigate = useNavigate();
const onSubmit = useEvent(async (values: SurveyEditFormValues) => {
const res = await createMutation.mutateAsync({
workspaceId,
name: values.name,
payload: values.payload,
});
utils.survey.all.refetch();
navigate({
to: '/survey/$surveyId',
params: {
surveyId: res.id,
},
});
});
return (
<CommonWrapper
header={<h1 className="text-xl font-bold">{t('Add Survey')}</h1>}
>
<div className="p-4">
<SurveyEditForm onSubmit={onSubmit} />
</div>
</CommonWrapper>
);
}

View File

@ -65,7 +65,7 @@ function TelemetryAddComponent() {
return ( return (
<CommonWrapper <CommonWrapper
header={<h1 className="text-xl font-bold">{t('Add Website')}</h1>} header={<h1 className="text-xl font-bold">{t('Add Telemetry')}</h1>}
> >
<div className="p-4"> <div className="p-4">
<Form {...form}> <Form {...form}>

View File

@ -38,3 +38,14 @@ export function formatShortTime(val: number, formats = ['m', 's'], space = '') {
return t; return t;
} }
export function generateRandomString(length: number): string {
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}

View File

@ -9,6 +9,7 @@ import { serverStatusRouter } from './serverStatus';
import { auditLogRouter } from './auditLog'; import { auditLogRouter } from './auditLog';
import { billingRouter } from './billing'; import { billingRouter } from './billing';
import { telemetryRouter } from './telemetry'; import { telemetryRouter } from './telemetry';
import { surveyRouter } from './survey';
export const appRouter = router({ export const appRouter = router({
global: globalRouter, global: globalRouter,
@ -18,6 +19,7 @@ export const appRouter = router({
notification: notificationRouter, notification: notificationRouter,
monitor: monitorRouter, monitor: monitorRouter,
telemetry: telemetryRouter, telemetry: telemetryRouter,
survey: surveyRouter,
serverStatus: serverStatusRouter, serverStatus: serverStatusRouter,
auditLog: auditLogRouter, auditLog: auditLogRouter,
billing: billingRouter, billing: billingRouter,

View File

@ -16,7 +16,7 @@ import { SurveyPayloadSchema } from '../../prisma/zod/schemas';
import { buildCursorResponseSchema } from '../../utils/schema'; import { buildCursorResponseSchema } from '../../utils/schema';
import { fetchDataByCursor } from '../../utils/prisma'; import { fetchDataByCursor } from '../../utils/prisma';
export const telemetryRouter = router({ export const surveyRouter = router({
all: workspaceProcedure all: workspaceProcedure
.meta( .meta(
buildSurveyOpenapi({ buildSurveyOpenapi({
@ -89,6 +89,28 @@ export const telemetryRouter = router({
return count; return count;
}), }),
allResultCount: workspaceProcedure
.meta(
buildSurveyOpenapi({
method: 'GET',
path: '/allResultCount',
})
)
.output(z.record(z.string(), z.number()))
.query(async () => {
const res = await prisma.surveyResult.groupBy({
by: ['surveyId'],
_count: true,
});
return res.reduce<Record<string, number>>((prev, item) => {
if (item.surveyId) {
prev[item.surveyId] = item._count;
}
return prev;
}, {});
}),
submit: publicProcedure submit: publicProcedure
.meta( .meta(
buildSurveyOpenapi({ buildSurveyOpenapi({

View File

@ -50,12 +50,13 @@ export const workspaceRouter = router({
server: z.number(), server: z.number(),
telemetry: z.number(), telemetry: z.number(),
page: z.number(), page: z.number(),
survey: z.number(),
}) })
) )
.query(async ({ input }) => { .query(async ({ input }) => {
const { workspaceId } = input; const { workspaceId } = input;
const [website, monitor, telemetry, page] = await Promise.all([ const [website, monitor, telemetry, page, survey] = await Promise.all([
prisma.website.count({ prisma.website.count({
where: { where: {
workspaceId, workspaceId,
@ -76,6 +77,11 @@ export const workspaceRouter = router({
workspaceId, workspaceId,
}, },
}), }),
prisma.survey.count({
where: {
workspaceId,
},
}),
]); ]);
const server = getServerCount(workspaceId); const server = getServerCount(workspaceId);
@ -86,6 +92,7 @@ export const workspaceRouter = router({
server, server,
telemetry, telemetry,
page, page,
survey,
}; };
}), }),
updateDashboardOrder: workspaceOwnerProcedure updateDashboardOrder: workspaceOwnerProcedure