diff --git a/src/client/components/TipIcon.tsx b/src/client/components/TipIcon.tsx index 1efe87c..a4a4090 100644 --- a/src/client/components/TipIcon.tsx +++ b/src/client/components/TipIcon.tsx @@ -11,7 +11,7 @@ export const TipIcon: React.FC = React.memo((props) => { return ( - + diff --git a/src/client/components/survey/SurveyEditForm.tsx b/src/client/components/survey/SurveyEditForm.tsx new file mode 100644 index 0000000..ccae476 --- /dev/null +++ b/src/client/components/survey/SurveyEditForm.tsx @@ -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; + +function generateDefaultItem() { + return { + label: 'New Field', + name: 'field-' + generateRandomString(4), + type: 'text' as const, + }; +} + +interface SurveyEditFormProps { + defaultValues?: SurveyEditFormValues; + onSubmit: (values: SurveyEditFormValues) => Promise; +} +export const SurveyEditForm: React.FC = React.memo( + (props) => { + const { t } = useTranslation(); + + const [advancedMode, setAdvancedMode] = useState(false); + + const form = useForm({ + 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 ( +
+ + + + ( + + {t('Survey Name')} + + + + + {t('Survey Name to Display')} + + + + )} + /> + +
+

{t('Form Info')}

+
+ setAdvancedMode(checked)} + /> +
{t('Advanced Mode')}
+
+ + {fields.map((field, i) => ( +
+ + {t('Display Label')} + + + + + + + {advancedMode && ( + + + {t('Name')} + + + + + + + + )} + + + {t('Type')} + + + + + + + {/* actions */} +
+
+
+ ))} + +
+
+
+
+ + + + +
+
+ + ); + } +); +SurveyEditForm.displayName = 'SurveyEditForm'; diff --git a/src/client/pages/Layout/DesktopLayout.tsx b/src/client/pages/Layout/DesktopLayout.tsx index 69678f3..20b9596 100644 --- a/src/client/pages/Layout/DesktopLayout.tsx +++ b/src/client/pages/Layout/DesktopLayout.tsx @@ -4,6 +4,7 @@ import { LuFilePieChart, LuMonitorDot, LuServer, + LuTableProperties, LuWifi, } from 'react-icons/lu'; import { TooltipProvider } from '@/components/ui/tooltip'; @@ -97,6 +98,12 @@ export const DesktopLayout: React.FC = React.memo((props) => { icon: LuFilePieChart, to: '/page', }, + { + title: t('Survey'), + label: String(serviceCount?.survey ?? ''), + icon: LuTableProperties, + to: '/survey', + }, ]} /> diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts index ecbb363..09884f6 100644 --- a/src/client/routeTree.gen.ts +++ b/src/client/routeTree.gen.ts @@ -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 SurveyImport } from './routes/survey' import { Route as SettingsImport } from './routes/settings' import { Route as ServerImport } from './routes/server' 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 TelemetryAddImport } from './routes/telemetry/add' 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 SettingsUsageImport } from './routes/settings/usage' import { Route as SettingsProfileImport } from './routes/settings/profile' @@ -50,6 +53,11 @@ const TelemetryRoute = TelemetryImport.update({ getParentRoute: () => rootRoute, } as any) +const SurveyRoute = SurveyImport.update({ + path: '/survey', + getParentRoute: () => rootRoute, +} as any) + const SettingsRoute = SettingsImport.update({ path: '/settings', getParentRoute: () => rootRoute, @@ -110,6 +118,16 @@ const TelemetryTelemetryIdRoute = TelemetryTelemetryIdImport.update({ getParentRoute: () => TelemetryRoute, } 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({ path: '/status/$slug', getParentRoute: () => rootRoute, @@ -206,6 +224,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsImport parentRoute: typeof rootRoute } + '/survey': { + preLoaderRoute: typeof SurveyImport + parentRoute: typeof rootRoute + } '/telemetry': { preLoaderRoute: typeof TelemetryImport parentRoute: typeof rootRoute @@ -246,6 +268,14 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StatusSlugImport parentRoute: typeof rootRoute } + '/survey/$surveyId': { + preLoaderRoute: typeof SurveySurveyIdImport + parentRoute: typeof SurveyImport + } + '/survey/add': { + preLoaderRoute: typeof SurveyAddImport + parentRoute: typeof SurveyImport + } '/telemetry/$telemetryId': { preLoaderRoute: typeof TelemetryTelemetryIdImport parentRoute: typeof TelemetryImport @@ -301,6 +331,7 @@ export const routeTree = rootRoute.addChildren([ SettingsProfileRoute, SettingsUsageRoute, ]), + SurveyRoute.addChildren([SurveySurveyIdRoute, SurveyAddRoute]), TelemetryRoute.addChildren([TelemetryTelemetryIdRoute, TelemetryAddRoute]), WebsiteRoute.addChildren([ WebsiteAddRoute, diff --git a/src/client/routes/survey.tsx b/src/client/routes/survey.tsx new file mode 100644 index 0000000..4d25474 --- /dev/null +++ b/src/client/routes/survey.tsx @@ -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 ( + + {t('Add')} + + } + /> + } + > + + + } + /> + ); +} diff --git a/src/client/routes/survey/$surveyId.tsx b/src/client/routes/survey/$surveyId.tsx new file mode 100644 index 0000000..211ea39 --- /dev/null +++ b/src/client/routes/survey/$surveyId.tsx @@ -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 ( + }> + + + + {/* */} + + + ); +} diff --git a/src/client/routes/survey/add.tsx b/src/client/routes/survey/add.tsx new file mode 100644 index 0000000..466e29f --- /dev/null +++ b/src/client/routes/survey/add.tsx @@ -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 ( + {t('Add Survey')}} + > +
+ +
+
+ ); +} diff --git a/src/client/routes/telemetry/add.tsx b/src/client/routes/telemetry/add.tsx index f426e51..e0daa66 100644 --- a/src/client/routes/telemetry/add.tsx +++ b/src/client/routes/telemetry/add.tsx @@ -65,7 +65,7 @@ function TelemetryAddComponent() { return ( {t('Add Website')}} + header={

{t('Add Telemetry')}

} >
diff --git a/src/client/utils/common.ts b/src/client/utils/common.ts index 0ec42de..19d2c2e 100644 --- a/src/client/utils/common.ts +++ b/src/client/utils/common.ts @@ -38,3 +38,14 @@ export function formatShortTime(val: number, formats = ['m', 's'], space = '') { 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; +} diff --git a/src/server/trpc/routers/index.ts b/src/server/trpc/routers/index.ts index 93b753d..ea39b45 100644 --- a/src/server/trpc/routers/index.ts +++ b/src/server/trpc/routers/index.ts @@ -9,6 +9,7 @@ import { serverStatusRouter } from './serverStatus'; import { auditLogRouter } from './auditLog'; import { billingRouter } from './billing'; import { telemetryRouter } from './telemetry'; +import { surveyRouter } from './survey'; export const appRouter = router({ global: globalRouter, @@ -18,6 +19,7 @@ export const appRouter = router({ notification: notificationRouter, monitor: monitorRouter, telemetry: telemetryRouter, + survey: surveyRouter, serverStatus: serverStatusRouter, auditLog: auditLogRouter, billing: billingRouter, diff --git a/src/server/trpc/routers/survey.ts b/src/server/trpc/routers/survey.ts index 2b48734..408de30 100644 --- a/src/server/trpc/routers/survey.ts +++ b/src/server/trpc/routers/survey.ts @@ -16,7 +16,7 @@ import { SurveyPayloadSchema } from '../../prisma/zod/schemas'; import { buildCursorResponseSchema } from '../../utils/schema'; import { fetchDataByCursor } from '../../utils/prisma'; -export const telemetryRouter = router({ +export const surveyRouter = router({ all: workspaceProcedure .meta( buildSurveyOpenapi({ @@ -89,6 +89,28 @@ export const telemetryRouter = router({ 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>((prev, item) => { + if (item.surveyId) { + prev[item.surveyId] = item._count; + } + + return prev; + }, {}); + }), submit: publicProcedure .meta( buildSurveyOpenapi({ diff --git a/src/server/trpc/routers/workspace.ts b/src/server/trpc/routers/workspace.ts index ef7afed..8aa59e3 100644 --- a/src/server/trpc/routers/workspace.ts +++ b/src/server/trpc/routers/workspace.ts @@ -50,12 +50,13 @@ export const workspaceRouter = router({ server: z.number(), telemetry: z.number(), page: z.number(), + survey: z.number(), }) ) .query(async ({ input }) => { const { workspaceId } = input; - const [website, monitor, telemetry, page] = await Promise.all([ + const [website, monitor, telemetry, page, survey] = await Promise.all([ prisma.website.count({ where: { workspaceId, @@ -76,6 +77,11 @@ export const workspaceRouter = router({ workspaceId, }, }), + prisma.survey.count({ + where: { + workspaceId, + }, + }), ]); const server = getServerCount(workspaceId); @@ -86,6 +92,7 @@ export const workspaceRouter = router({ server, telemetry, page, + survey, }; }), updateDashboardOrder: workspaceOwnerProcedure