feat: add survey download feature

This commit is contained in:
moonrailgun 2024-05-05 14:10:12 +08:00
parent 6674c19e87
commit eebf00f882
10 changed files with 521 additions and 46 deletions

View File

@ -155,6 +155,12 @@ importers:
'@radix-ui/react-menubar':
specifier: ^1.0.4
version: 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-popover':
specifier: ^1.0.7
version: 1.0.7(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-progress':
specifier: ^1.0.3
version: 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-scroll-area':
specifier: ^1.0.5
version: 1.0.5(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0)
@ -200,12 +206,15 @@ importers:
'@trpc/react-query':
specifier: ^10.45.0
version: 10.45.0(@tanstack/react-query@4.33.0)(@trpc/client@10.45.0)(@trpc/server@10.45.0)(react-dom@18.2.0)(react@18.2.0)
'@types/jsonexport':
specifier: ^3.0.5
version: 3.0.5
ahooks:
specifier: ^3.7.10
version: 3.7.10(react@18.2.0)
antd:
specifier: ^5.13.1
version: 5.13.1(react-dom@18.2.0)(react@18.2.0)
version: 5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0)
array-move:
specifier: ^3.0.1
version: 3.0.1
@ -230,6 +239,9 @@ importers:
copy-to-clipboard:
specifier: ^3.3.3
version: 3.3.3
date-fns:
specifier: ^3.6.0
version: 3.6.0
dayjs:
specifier: 1.11.10
version: 1.11.10
@ -242,6 +254,9 @@ importers:
fuse.js:
specifier: ^7.0.0
version: 7.0.0
jsonexport:
specifier: ^3.2.0
version: 3.2.0
leaflet:
specifier: ^1.9.4
version: 1.9.4
@ -263,6 +278,9 @@ importers:
react:
specifier: ^18.2.0
version: 18.2.0
react-day-picker:
specifier: ^8.10.1
version: 8.10.1(date-fns@3.6.0)(react@18.2.0)
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
@ -881,7 +899,7 @@ packages:
'@ant-design/icons': 5.3.6(react-dom@18.2.0)(react@18.2.0)
'@ant-design/maps': 1.0.8(react-dom@18.2.0)(react@18.2.0)
'@ant-design/plots': 1.2.6(react-dom@18.2.0)(react@18.2.0)
antd: 5.13.1(react-dom@18.2.0)(react@18.2.0)
antd: 5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0)
lodash: 4.17.21
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
@ -953,7 +971,7 @@ packages:
'@antv/x6-react-components': 1.1.20(antd@5.13.1)(react-dom@18.2.0)(react@18.2.0)
'@antv/x6-react-shape': 1.6.5(@antv/x6@1.35.0)(react-dom@18.2.0)(react@18.2.0)
'@antv/xflow': 1.1.52(@ant-design/icons@5.3.6)(antd@5.13.1)(classnames@2.5.1)(lodash@4.17.21)(react-dom@18.2.0)(react@18.2.0)(reflect-metadata@0.1.14)
antd: 5.13.1(react-dom@18.2.0)(react@18.2.0)
antd: 5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0)
lodash: 4.17.21
react: 18.2.0
react-color: 2.17.3(react@18.2.0)
@ -1737,7 +1755,7 @@ packages:
react: '>=16.8.6 || >=17.0.0'
react-dom: '>=16.8.6 || >=17.0.0'
dependencies:
antd: 5.13.1(react-dom@18.2.0)(react@18.2.0)
antd: 5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0)
clamp: 1.0.1
classnames: 2.5.1
rc-dropdown: 3.6.2(react-dom@18.2.0)(react@18.2.0)
@ -1789,7 +1807,7 @@ packages:
'@antv/x6-react-components': 1.1.20(antd@5.13.1)(react-dom@18.2.0)(react@18.2.0)
'@antv/x6-react-shape': 1.6.5(@antv/x6@1.35.0)(react-dom@18.2.0)(react@18.2.0)
'@antv/xflow-hook': 1.0.52
antd: 5.13.1(react-dom@18.2.0)(react@18.2.0)
antd: 5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0)
classnames: 2.5.1
immer: 9.0.21
lodash: 4.17.21
@ -1820,7 +1838,7 @@ packages:
'@antv/x6-react-shape': 1.6.5(@antv/x6@1.35.0)(react-dom@18.2.0)(react@18.2.0)
'@antv/xflow-core': 1.1.52(@ant-design/icons@5.3.6)(@antv/x6-react-components@1.1.20)(@antv/x6-react-shape@1.6.5)(@antv/x6@1.35.0)(antd@5.13.1)(lodash@4.17.21)(react-dom@18.2.0)(react@18.2.0)
'@antv/xflow-hook': 1.0.52
antd: 5.13.1(react-dom@18.2.0)(react@18.2.0)
antd: 5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0)
classnames: 2.5.1
mana-syringe: 0.2.2
moment: 2.30.1
@ -1862,7 +1880,7 @@ packages:
'@antv/xflow-core': 1.1.52(@ant-design/icons@5.3.6)(@antv/x6-react-components@1.1.20)(@antv/x6-react-shape@1.6.5)(@antv/x6@1.35.0)(antd@5.13.1)(lodash@4.17.21)(react-dom@18.2.0)(react@18.2.0)
'@antv/xflow-extension': 1.1.52(@ant-design/icons@5.3.6)(@antv/x6-react-components@1.1.20)(@antv/x6-react-shape@1.6.5)(@antv/x6@1.35.0)(antd@5.13.1)(classnames@2.5.1)(lodash@4.17.21)(react-dom@18.2.0)(react@18.2.0)(reflect-metadata@0.1.14)
'@antv/xflow-hook': 1.1.52
antd: 5.13.1(react-dom@18.2.0)(react@18.2.0)
antd: 5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0)
lodash: 4.17.21
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
@ -7776,6 +7794,41 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-popover@1.0.7(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.24.0
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.78)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.78)(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.78)(react@18.2.0)
'@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-id': 1.0.1(@types/react@18.2.78)(react@18.2.0)
'@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot': 1.0.2(@types/react@18.2.78)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.78)(react@18.2.0)
'@types/react': 18.2.78
'@types/react-dom': 18.2.7
aria-hidden: 1.2.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-remove-scroll: 2.5.5(@types/react@18.2.78)(react@18.2.0)
dev: false
/@radix-ui/react-popper@1.1.3(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==}
peerDependencies:
@ -7870,6 +7923,28 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-progress@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.24.0
'@radix-ui/react-context': 1.0.1(@types/react@18.2.78)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0)
'@types/react': 18.2.78
'@types/react-dom': 18.2.7
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==}
peerDependencies:
@ -10242,6 +10317,12 @@ packages:
/@types/json-schema@7.0.15:
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
/@types/jsonexport@3.0.5:
resolution: {integrity: sha512-+zDVaDqNguePOU11YNEsuf/ZRS1ciHeKVYGjfvDFmgG6aCy/s4W4Wy1MiXJIbiyMJLuakGLENCndvmGZpVTV1w==}
dependencies:
'@types/node': 18.17.12
dev: false
/@types/jsonfile@6.1.3:
resolution: {integrity: sha512-/yqTk2SZ1wIezK0hiRZD7RuSf4B3whFxFamB1kGStv+8zlWScTMcHanzfc0XKWs5vA1TkHeckBlOyM8jxU8nHA==}
dependencies:
@ -11169,7 +11250,7 @@ packages:
- moment
dev: false
/antd@5.13.1(react-dom@18.2.0)(react@18.2.0):
/antd@5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-/qAPsr6UyJPSFZQD9G7kW98GelH2Bajli+1q7CRW4IinYQ0R0UVJckFX11emByhiU4Jd4WNH/hOO+fZtp0eVDA==}
peerDependencies:
react: '>=16.9.0'
@ -11203,7 +11284,7 @@ packages:
rc-motion: 2.9.0(react-dom@18.2.0)(react@18.2.0)
rc-notification: 5.3.0(react-dom@18.2.0)(react@18.2.0)
rc-pagination: 4.0.4(react-dom@18.2.0)(react@18.2.0)
rc-picker: 3.14.6(dayjs@1.11.10)(react-dom@18.2.0)(react@18.2.0)
rc-picker: 3.14.6(date-fns@3.6.0)(dayjs@1.11.10)(react-dom@18.2.0)(react@18.2.0)
rc-progress: 3.5.1(react-dom@18.2.0)(react@18.2.0)
rc-rate: 2.12.0(react-dom@18.2.0)(react@18.2.0)
rc-resize-observer: 1.4.0(react-dom@18.2.0)(react@18.2.0)
@ -13675,6 +13756,10 @@ packages:
dependencies:
'@babel/runtime': 7.24.0
/date-fns@3.6.0:
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
dev: false
/dayjs@1.11.10:
resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==}
dev: false
@ -17455,6 +17540,11 @@ packages:
resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
dev: true
/jsonexport@3.2.0:
resolution: {integrity: sha512-GbO9ugb0YTZatPd/hqCGR0FSwbr82H6OzG04yzdrG7XOe4QZ0jhQ+kOsB29zqkzoYJLmLxbbrFiuwbQu891XnQ==}
hasBin: true
dev: false
/jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
optionalDependencies:
@ -20809,7 +20899,7 @@ packages:
dependencies:
lilconfig: 2.1.0
postcss: 8.4.33
ts-node: 10.9.1(@types/node@18.17.12)(typescript@4.7.4)
ts-node: 10.9.1(@types/node@18.17.12)(typescript@5.4.5)
yaml: 2.3.2
dev: true
@ -22496,6 +22586,36 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/rc-picker@3.14.6(date-fns@3.6.0)(dayjs@1.11.10)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-AdKKW0AqMwZsKvIpwUWDUnpuGKZVrbxVTZTNjcO+pViGkjC1EBcjMgxVe8tomOEaIHJL5Gd13vS8Rr3zzxWmag==}
engines: {node: '>=8.x'}
peerDependencies:
date-fns: '>= 2.x'
dayjs: 1.11.10
luxon: '>= 3.x'
moment: '>= 2.x'
react: '>=16.9.0'
react-dom: '>=16.9.0'
peerDependenciesMeta:
date-fns:
optional: true
dayjs:
optional: true
luxon:
optional: true
moment:
optional: true
dependencies:
'@babel/runtime': 7.24.0
'@rc-component/trigger': 1.18.2(react-dom@18.2.0)(react@18.2.0)
classnames: 2.5.1
date-fns: 3.6.0
dayjs: 1.11.10
rc-util: 5.38.1(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/rc-picker@3.14.6(dayjs@1.11.10)(react-dom@17.0.2)(react@17.0.2):
resolution: {integrity: sha512-AdKKW0AqMwZsKvIpwUWDUnpuGKZVrbxVTZTNjcO+pViGkjC1EBcjMgxVe8tomOEaIHJL5Gd13vS8Rr3zzxWmag==}
engines: {node: '>=8.x'}
@ -22525,35 +22645,6 @@ packages:
react-dom: 17.0.2(react@17.0.2)
dev: false
/rc-picker@3.14.6(dayjs@1.11.10)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-AdKKW0AqMwZsKvIpwUWDUnpuGKZVrbxVTZTNjcO+pViGkjC1EBcjMgxVe8tomOEaIHJL5Gd13vS8Rr3zzxWmag==}
engines: {node: '>=8.x'}
peerDependencies:
date-fns: '>= 2.x'
dayjs: 1.11.10
luxon: '>= 3.x'
moment: '>= 2.x'
react: '>=16.9.0'
react-dom: '>=16.9.0'
peerDependenciesMeta:
date-fns:
optional: true
dayjs:
optional: true
luxon:
optional: true
moment:
optional: true
dependencies:
'@babel/runtime': 7.24.0
'@rc-component/trigger': 1.18.2(react-dom@18.2.0)(react@18.2.0)
classnames: 2.5.1
dayjs: 1.11.10
rc-util: 5.38.1(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/rc-progress@3.5.1(react-dom@17.0.2)(react@17.0.2):
resolution: {integrity: sha512-V6Amx6SbLRwPin/oD+k1vbPrO8+9Qf8zW1T8A7o83HdNafEVvAxPV5YsgtKFP+Ud5HghLj33zKOcEHrcrUGkfw==}
peerDependencies:
@ -23138,6 +23229,16 @@ packages:
react: 18.2.0
dev: false
/react-day-picker@8.10.1(date-fns@3.6.0)(react@18.2.0):
resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==}
peerDependencies:
date-fns: ^2.28.0 || ^3.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
date-fns: 3.6.0
react: 18.2.0
dev: false
/react-dev-utils@12.0.1(typescript@4.7.4)(webpack@5.89.0):
resolution: {integrity: sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==}
engines: {node: '>=14'}
@ -23488,6 +23589,7 @@ packages:
/react-remove-scroll-bar@2.3.5(@types/react@18.2.78)(react@18.2.0):
resolution: {integrity: sha512-3cqjOqg6s0XbOjWvmasmqHch+RLxIEk2r/70rzGXuz3iIGQsQheEQyqYCBb5EECoD01Vo2SIbDqW4paLeLTASw==}
engines: {node: '>=10'}
deprecated: please update to the following version as this contains a bug (https://github.com/theKashey/react-remove-scroll-bar/issues/57)
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
@ -26317,7 +26419,6 @@ packages:
typescript: 5.4.5
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
dev: false
/ts-pattern@4.3.0:
resolution: {integrity: sha512-pefrkcd4lmIVR0LA49Imjf9DYLK8vtWhqBPA3Ya1ir8xCW0O2yjL9dsCVvI7pCodLC5q7smNpEtDR2yVulQxOg==}

View File

@ -0,0 +1,199 @@
import React, { useRef, useState } from 'react';
import {
DialogHeader,
DialogFooter,
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@i18next-toolkit/react';
import { LuCalendar } from 'react-icons/lu';
import { cn } from '@/utils/style';
import dayjs from 'dayjs';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Calendar } from '../ui/calendar';
import { DateRange } from 'react-day-picker';
import { AppRouterOutput, trpc } from '@/api/trpc';
import { useCurrentWorkspaceId } from '@/store/user';
import { useEvent } from '@/hooks/useEvent';
import { clamp, pick } from 'lodash-es';
import { Progress } from '../ui/progress';
import jsonExport from 'jsonexport/dist';
import { downloadCSV } from '@/utils/dom';
import { message } from 'antd';
interface SurveyDownloadBtnProps {
surveyId: string;
}
export const SurveyDownloadBtn: React.FC<SurveyDownloadBtnProps> = React.memo(
(props) => {
const { surveyId } = props;
const workspaceId = useCurrentWorkspaceId();
const [date, setDate] = useState<DateRange | undefined>({
from: dayjs().subtract(1, 'months').toDate(),
to: dayjs().toDate(),
});
const { t } = useTranslation();
const { data: info } = trpc.survey.get.useQuery({
workspaceId,
surveyId,
});
const { data: count = 1 } = trpc.survey.count.useQuery({
workspaceId,
surveyId,
});
const [downloadProgress, setDownloadProgress] = useState<number | null>(
null
);
const trpcUtils = trpc.useUtils();
const handleStart = useEvent(async () => {
try {
const limit = 1000;
let cursor: string | undefined = undefined;
const downloadResultList: AppRouterOutput['survey']['resultList']['items'][number][] =
[];
setDownloadProgress(0);
const startAt = date?.from
? dayjs(date.from).startOf('day').valueOf()
: undefined;
const endAt = date?.to
? dayjs(date.to).endOf('day').valueOf()
: undefined;
while (true) {
const { items, nextCursor } = await trpcUtils.survey.resultList.fetch(
{
workspaceId,
surveyId,
limit: 1000,
cursor,
startAt,
endAt,
}
);
downloadResultList.push(...items);
setDownloadProgress(clamp(downloadResultList.length / count, 0, 1));
cursor = nextCursor;
if (items.length < limit) {
// no more
break;
}
}
// download with csv file
const fields = info?.payload.items ?? [];
const csv = await jsonExport(
downloadResultList.map((item) => {
const map = fields.reduce(
(prev, curr) => ({
...prev,
[curr.label]: item.payload[curr.name],
}),
{} as Record<string, string>
);
return {
id: item.id,
sessionId: item.sessionId,
...map,
...pick(item, [
'language',
'browser',
'os',
'country',
'subdivision1',
'subdivision2',
'city',
]),
};
})
);
let filename = info?.name ?? surveyId;
if (date && startAt && endAt) {
filename += `-${dayjs(startAt).format('YYYY-MM-DD')}-${dayjs(endAt).format('YYYY-MM-DD')}`;
}
downloadCSV(csv, filename);
} catch (err) {
message.error(String(err));
} finally {
setDownloadProgress(null);
}
});
return (
<Dialog>
<DialogTrigger asChild>
<Button>{t('Download')}</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t('Download')}</DialogTitle>
<DialogDescription>
{t('Download survey data with csv for further use.')}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={'outline'}
className={cn(
'w-[300px] justify-start text-left font-normal',
!date && 'text-muted-foreground'
)}
>
<LuCalendar className="mr-2 h-4 w-4" />
{date?.from ? (
date.to ? (
<>
{dayjs(date.from).format('MMM DD, YYYY')} -{' '}
{dayjs(date.to).format('MMM DD, YYYY')}
</>
) : (
dayjs(date.from).format('MMM DD, YYYY')
)
) : (
<span>{t('Pick a date')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
initialFocus
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={setDate}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
<DialogFooter className="sm:items-center sm:justify-between">
<div className="w-full sm:w-[120px]">
{typeof downloadProgress === 'number' && (
<Progress value={downloadProgress} />
)}
</div>
<Button
loading={typeof downloadProgress === 'number'}
onClick={handleStart}
>
{t('Start')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
);
SurveyDownloadBtn.displayName = 'SurveyDownloadBtn';

View File

@ -0,0 +1,70 @@
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"
import { DayPicker } from "react-day-picker"
import { cn } from "@/utils/style"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeftIcon className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRightIcon className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@ -0,0 +1,31 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/utils/style"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/utils/style"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -35,6 +35,8 @@
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-menubar": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
@ -50,6 +52,7 @@
"@tianji/shared": "workspace:^",
"@trpc/client": "^10.45.0",
"@trpc/react-query": "^10.45.0",
"@types/jsonexport": "^3.0.5",
"ahooks": "^3.7.10",
"antd": "^5.13.1",
"array-move": "^3.0.1",
@ -60,10 +63,12 @@
"cmdk": "^1.0.0",
"colord": "^2.9.3",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"dayjs": "^1.11.9",
"eventemitter-strict": "^1.0.1",
"filesize": "^10.0.12",
"fuse.js": "^7.0.0",
"jsonexport": "^3.2.0",
"leaflet": "^1.9.4",
"lodash-es": "^4.17.21",
"lucide-react": "^0.358.0",
@ -71,6 +76,7 @@
"next-themes": "^0.2.1",
"pretty-ms": "^9.0.0",
"react": "^18.2.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.2.0",
"react-easy-sort": "^1.5.3",
"react-grid-layout": "1.4.2",

View File

@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DataTable, createColumnHelper } from '@/components/DataTable';
import { useMemo } from 'react';
import { SurveyDownloadBtn } from '@/components/survey/SurveyDownloadBtn';
type SurveyResultItem =
AppRouterOutput['survey']['resultList']['items'][number];
@ -126,7 +127,12 @@ function PageComponent() {
<CardHeader>
<CardTitle>{t('Count')}</CardTitle>
</CardHeader>
<CardContent>{count}</CardContent>
<CardContent className="flex justify-between">
<div>{count}</div>
<div>
<SurveyDownloadBtn surveyId={surveyId} />
</div>
</CardContent>
</Card>
</div>

View File

@ -13,3 +13,20 @@ export function preventDefault(e: Event) {
}
export const rootEl = document.getElementById('root');
export function downloadCSV(csv: string, filename: string): void {
const fakeLink = document.createElement('a');
fakeLink.style.display = 'none';
document.body.appendChild(fakeLink);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
// @ts-ignore
if (window.navigator && window.navigator.msSaveOrOpenBlob) {
// Manage IE11+ & Edge
// @ts-ignore
window.navigator.msSaveOrOpenBlob(blob, `${filename}.csv`);
} else {
fakeLink.setAttribute('href', URL.createObjectURL(blob));
fakeLink.setAttribute('download', `${filename}.csv`);
fakeLink.click();
}
}

View File

@ -15,6 +15,7 @@ import { getRequestInfo } from '../../utils/detect';
import { SurveyPayloadSchema } from '../../prisma/zod/schemas';
import { buildCursorResponseSchema } from '../../utils/schema';
import { fetchDataByCursor } from '../../utils/prisma';
import { Prisma } from '@prisma/client';
export const surveyRouter = router({
all: workspaceProcedure
@ -259,8 +260,10 @@ export const surveyRouter = router({
.input(
z.object({
surveyId: z.string(),
limit: z.number().min(1).max(100).default(50),
limit: z.number().min(1).max(1000).default(50),
cursor: z.string().optional(),
startAt: z.number().optional(),
endAt: z.number().optional(),
})
)
.output(buildCursorResponseSchema(SurveyResultModelSchema))
@ -268,12 +271,21 @@ export const surveyRouter = router({
const limit = input.limit;
const { cursor, surveyId } = input;
const where: Prisma.SurveyResultWhereInput = {
surveyId,
};
if (input.startAt && input.endAt) {
where.createdAt = {
gte: new Date(input.startAt),
lte: new Date(input.endAt),
};
}
const { items, nextCursor } = await fetchDataByCursor(
prisma.surveyResult,
{
where: {
surveyId,
},
where,
limit,
cursor,
}

View File

@ -220,6 +220,12 @@ type ExtractFindManyReturnType<T> = T extends (
? R
: never;
type ExtractFindManyWhereType<
T extends {
findMany: (args?: any) => Prisma.PrismaPromise<any>;
},
> = NonNullable<Parameters<T['findMany']>[0]>['where'];
/**
* @example
* const { items, nextCursor } = await fetchDataByCursor(
@ -241,7 +247,8 @@ export async function fetchDataByCursor<
>(
fetchModel: Model,
options: {
where: Record<string, string>;
// where: Record<string, any>;
where: ExtractFindManyWhereType<Model>;
limit: number;
cursor: CursorType;
cursorName?: string;