feat(eventSearch): export to csv is now available
Build and Push Docker image / build-and-push (push) Successful in 2m5s Details

This commit is contained in:
Valentin Kolb 2024-06-01 01:50:04 +02:00
parent 4f401f4eda
commit 54057be1f6
6 changed files with 317 additions and 64 deletions

View File

@ -9,5 +9,5 @@ export const PB_STORAGE_KEY = "stuve-it-login-record"
// general // general
export const APP_NAME = "StuVe IT" export const APP_NAME = "StuVe IT"
export const APP_VERSION = "0.8.5 (beta)" export const APP_VERSION = "0.8.9 (beta)"
export const APP_URL = "https://it.stuve.uni-ulm.de" export const APP_URL = "https://it.stuve.uni-ulm.de"

View File

@ -0,0 +1,232 @@
import {EventListModel, EventListSlotEntriesWithUserModel, EventModel} from "@/models/EventTypes.ts";
import {ListResult} from "pocketbase";
import {InfiniteData, UseInfiniteQueryResult} from "@tanstack/react-query";
import {Button, Checkbox, Fieldset, Group, Modal, Progress, Select, Text} from "@mantine/core";
import ShowHelp from "@/components/ShowHelp.tsx";
import {useForm} from "@mantine/form";
import {useEffect} from "react";
import {FormSchema} from "@/components/formUtil/formBuilder/types.ts";
import {IconDownload} from "@tabler/icons-react";
const CHARSETS = [
"utf-8", "ISO-8859-1", "windows-1252", "utf-16be", "utf-16le", "us-ascii"
] as const
type FormValues = {
questionSchemaFields: string[]
statusSchemaFields: string[],
separator: "," | ";" | "\t",
charset: typeof CHARSETS[number]
}
export default function DownloadDataModal({opened, onClose, lists, event, query}: {
opened: boolean
onClose: () => void
event: EventModel
lists: EventListModel[]
query: UseInfiniteQueryResult<InfiniteData<ListResult<EventListSlotEntriesWithUserModel>, unknown>, Error>
}) {
const formValues = useForm<FormValues>({
initialValues: {
questionSchemaFields: [],
statusSchemaFields: [],
separator: ",",
charset: "utf-8"
}
})
// Fetch all pages
useEffect(() => {
if (opened && query.hasNextPage && !query.isFetchingNextPage) {
query.fetchNextPage()
}
}, [opened, query])
const RenderFieldCheckboxes = ({schema, formKey}: {
schema: FormSchema | null,
formKey: 'questionSchemaFields' | 'statusSchemaFields'
}) => (
schema?.fields.map(field => (
<Checkbox
key={field.id}
label={field.label}
checked={formValues.values[formKey]?.includes(field.id)}
onChange={() => {
formValues.setFieldValue(formKey, formValues.values[formKey]?.includes(field.id) ?
formValues.values[formKey]?.filter(f => f !== field.id) :
[...formValues.values[formKey], field.id])
}}
/>
))
)
const totalPages = query.data?.pages?.[0].totalPages || 0
const currentPage = query.data?.pages?.length || 0
const progress = currentPage / totalPages * 100
const dataIsFetching = query.isFetching || query.isPending
const createCSV = () => {
// all loaded entries
const entries = query.data?.pages.flatMap(p => p.items) ?? []
const selectedFields = {
questionSchemaFields: formValues.values.questionSchemaFields,
statusSchemaFields: formValues.values.statusSchemaFields
}
// which question and status fields to include in the CSV
const questionSchema = {
fields: [
...event.defaultEntryQuestionSchema?.fields.filter(f => selectedFields.questionSchemaFields.includes(f.id)) ?? [],
...lists.flatMap(l => l.entryQuestionSchema?.fields.filter(f => selectedFields.questionSchemaFields.includes(f.id)) ?? [])
]
}
const statusSchema = {
fields: [
...event.defaultEntryStatusSchema?.fields.filter(f => selectedFields.statusSchemaFields.includes(f.id)) ?? [],
...lists.flatMap(l => l.entryStatusSchema?.fields.filter(f => selectedFields.statusSchemaFields.includes(f.id)) ?? [])
]
}
/**
* Escapes the csv separator if it is present in the string
*
* trims the string in any case
* @param str the string to escape
*/
const escapeSeparator = (str: string) => {
if (str.includes(formValues.values.separator)) {
return `"${str.trim()}"`
}
return str.trim()
}
// assemble the header (first question fields, then status fields)
const header = [
"Person", "Person ID", "Anmeldezeitpunkt", "Anmelde-ID", "Anmelde-Liste",
...questionSchema.fields.map(f => f.label).map(escapeSeparator),
...statusSchema.fields.map(f => f.label).map(escapeSeparator)
]
// assemble the data
const data = entries.map(e => {
const questionData = e.entryQuestionData || {}
const statusData = e.entryStatusData || {}
return [
e.expand?.user.username ?? "N/A", e.user, e.created, e.id, e.listName,
...questionSchema.fields.map(f => questionData[f.id]?.value?.toString() ?? "N/A").map(escapeSeparator),
...statusSchema.fields.map(f => statusData[f.id]?.value?.toString() ?? "N/A").map(escapeSeparator)
]
})
// assemble CSV
const csv = [
header.join(formValues.values.separator),
...data.map(row => row.join(formValues.values.separator))
].join("\r\n")
const csvName = `${event.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_export.csv`
// download
const blob = new Blob([csv], {type: "text/csv;charset=" + formValues.values.charset})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = csvName
a.style.display = "hidden"
a.click()
URL.revokeObjectURL(url)
onClose()
}
return <Modal
opened={opened}
onClose={onClose}
title="Daten herunterladen"
size="xl"
>
<div className={"stack"}>
<ShowHelp>
In der CSV sind automatisch die Felder <em>Person</em>, <em>Person
ID</em>, <em>Anmeldezeitpunkt</em>, <em>Anmelde-ID</em> und <em>Anmelde-Liste</em> enthalten. Diese
Felder können nicht abgewählt werden.
<br/>
Um Listen spezifische Daten herunterzuladen, wähle im vorherigen Schritt eine oder mehrere Listen aus.
</ShowHelp>
{dataIsFetching && <>
<Text c={"dimmed"} size={"xs"}>
Daten werden gesammelt ...
</Text>
<Progress.Root size="xl">
<Progress.Section value={progress} animated>
<Progress.Label>{progress.toFixed(0)}%</Progress.Label>
</Progress.Section>
</Progress.Root>
</>}
<Fieldset legend={event.name}>
<div className={"stack"}>
<Text c={"dimmed"} size={"xs"}>
Formularfelder
</Text>
<RenderFieldCheckboxes formKey={"questionSchemaFields"} schema={event.defaultEntryQuestionSchema}/>
<Text c={"dimmed"} size={"xs"}>
Statusfelder
</Text>
<RenderFieldCheckboxes formKey={"statusSchemaFields"} schema={event.defaultEntryStatusSchema}/>
</div>
</Fieldset>
{
lists.map(list => <Fieldset key={list.id} legend={list.name}>
<div className={"stack"}>
<Text c={"dimmed"} size={"xs"}>
Formularfelder
</Text>
<RenderFieldCheckboxes formKey={"questionSchemaFields"} schema={list.entryQuestionSchema}/>
<Text c={"dimmed"} size={"xs"}>
Statusfelder
</Text>
<RenderFieldCheckboxes formKey={"statusSchemaFields"} schema={list.entryStatusSchema}/>
</div>
</Fieldset>)
}
<Select
label={"CSV-Trennzeichen"}
placeholder={"Wähle ein Trennzeichen"}
data={[
{value: ",", label: "Komma"},
{value: ";", label: "Semikolon"},
{value: "\t", label: "Tabulator"}
]}
{...formValues.getInputProps("separator")}
/>
<Select
label={"Zeichensatz"}
placeholder={"Wähle einen Zeichensatz"}
data={CHARSETS.map(c => ({value: c, label: c.toUpperCase()}))}
{...formValues.getInputProps("charset")}
/>
<Group>
<Button onClick={onClose} variant={"light"} color={"orange"}>
Abbrechen
</Button>
<Button
onClick={createCSV}
disabled={dataIsFetching}
loading={dataIsFetching}
leftSection={<IconDownload/>}
>
CSV herunterladen
</Button>
</Group>
</div>
</Modal>
}

View File

@ -32,12 +32,17 @@ import {FieldEntriesFilter} from "@/components/formUtil/FromInput/types.ts";
import {assembleFilter} from "@/components/formUtil/FormFilter/util.ts"; import {assembleFilter} from "@/components/formUtil/FormFilter/util.ts";
import {DateTimePicker} from "@mantine/dates"; import {DateTimePicker} from "@mantine/dates";
import EventEntries from "@/pages/events/e/:eventId/EventLists/Search/EventEntries.tsx"; import EventEntries from "@/pages/events/e/:eventId/EventLists/Search/EventEntries.tsx";
import {useEventRights} from "@/pages/events/util.ts";
import DownloadDataModal from "@/pages/events/e/:eventId/EventLists/Search/DownloadDataModal.tsx";
export default function ListSearch({event}: { event: EventModel }) { export default function ListSearch({event}: { event: EventModel }) {
const {pb} = usePB() const {pb} = usePB()
const {canEditEvent} = useEventRights(event)
const [showFilter, showFilterHandler] = useDisclosure(true) const [showFilter, showFilterHandler] = useDisclosure(true)
const [showDownloadModal, showDownloadModalHandler] = useDisclosure(false)
const formValues = useForm({ const formValues = useForm({
initialValues: { initialValues: {
@ -197,7 +202,12 @@ export default function ListSearch({event}: { event: EventModel }) {
{entriesCount} Personen benachrichtigen {entriesCount} Personen benachrichtigen
</Button> </Button>
<Button size={"xs"} leftSection={<IconCsv size={16}/>} disabled> <Button
size={"xs"}
disabled={!canEditEvent}
leftSection={<IconCsv size={16}/>}
onClick={showDownloadModalHandler.toggle}
>
Daten exportieren Daten exportieren
</Button> </Button>
</Group> </Group>
@ -220,6 +230,14 @@ export default function ListSearch({event}: { event: EventModel }) {
<EventEntries event={event} entries={entries} refetch={() => query.refetch()}/> <EventEntries event={event} entries={entries} refetch={() => query.refetch()}/>
<DownloadDataModal
event={event}
opened={showDownloadModal}
onClose={showDownloadModalHandler.toggle}
query={query}
lists={formValues.values.selectedLists}
/>
{query.hasNextPage && ( {query.hasNextPage && (
<Center p={"xs"}> <Center p={"xs"}>
<Button <Button

View File

@ -13,6 +13,7 @@ import {useNavigate} from "react-router-dom";
import {getListSchemas} from "@/pages/events/util.ts"; import {getListSchemas} from "@/pages/events/util.ts";
import {RenderDateRange} from "@/pages/events/e/:eventId/EventLists/EventListComponents/RenderDateRange.tsx"; import {RenderDateRange} from "@/pages/events/e/:eventId/EventLists/EventListComponents/RenderDateRange.tsx";
import SlotProgress from "@/pages/events/e/:eventId/EventLists/EventListComponents/SlotProgress.tsx"; import SlotProgress from "@/pages/events/e/:eventId/EventLists/EventListComponents/SlotProgress.tsx";
import EventLoginWarning from "@/pages/events/s/EventLoginWarning.tsx";
export default function EventListSlotView({slot, list, refetch}: { export default function EventListSlotView({slot, list, refetch}: {
list: EventListModel, list: EventListModel,
@ -75,7 +76,7 @@ export default function EventListSlotView({slot, list, refetch}: {
} }
{ {
!user ? <EventLoginWarning/> :
list.onlyStuVeAccounts && user?.REALM !== "LDAP" ? <> list.onlyStuVeAccounts && user?.REALM !== "LDAP" ? <>
<Alert color={"red"}> <Alert color={"red"}>
Für diese Liste sind nur StuVe-Accounts zugelassen Für diese Liste sind nur StuVe-Accounts zugelassen

View File

@ -0,0 +1,40 @@
import {usePB} from "@/lib/pocketbase.tsx";
import {ActionIcon, Alert, Text} from "@mantine/core";
import {IconLogin} from "@tabler/icons-react";
import {useLogin} from "@/components/users/modals/hooks.ts";
/**
* Display a warning if the user is not logged in
* @constructor
*/
export default function EventLoginWarning() {
const {user} = usePB()
const {handler: loginHandler} = useLogin()
return <>
{!user && <div className={"section-transparent"}>
<Alert icon={
<ActionIcon
color={"green"}
variant={"transparent"}
onClick={loginHandler.open}
>
<IconLogin/>
</ActionIcon>
} color={"orange"}>
Um dich in eine Liste für dieses Event einzutragen, musst du dich
{" "}
<Text
onClick={loginHandler.open}
size={"sm"}
fw={700}
span
td={"underline"}
style={{cursor: "pointer"}}
>
anmelden oder einen Gastaccount erstellen
</Text>
</Alert>
</div>}
</>
}

View File

@ -2,31 +2,19 @@ import {Link, useParams, useSearchParams} from "react-router-dom";
import {usePB} from "@/lib/pocketbase.tsx"; import {usePB} from "@/lib/pocketbase.tsx";
import {useQuery} from "@tanstack/react-query"; import {useQuery} from "@tanstack/react-query";
import NotFound from "../../not-found/index.page.tsx"; import NotFound from "../../not-found/index.page.tsx";
import { import {Accordion, Alert, Anchor, Breadcrumbs, Button, Center, Group, Loader, Title} from "@mantine/core";
Accordion,
ActionIcon,
Alert,
Anchor,
Breadcrumbs,
Button,
Center,
Group,
Loader,
Text,
Title
} from "@mantine/core";
import PBAvatar from "@/components/PBAvatar.tsx"; import PBAvatar from "@/components/PBAvatar.tsx";
import InnerHtml from "@/components/InnerHtml"; import InnerHtml from "@/components/InnerHtml";
import {IconArchive, IconExternalLink, IconEye, IconLogin, IconPencil, IconSectionSign} from "@tabler/icons-react"; import {IconArchive, IconExternalLink, IconEye, IconPencil, IconSectionSign} from "@tabler/icons-react";
import EventData from "@/pages/events/e/:eventId/EventComponents/EventData.tsx"; import EventData from "@/pages/events/e/:eventId/EventComponents/EventData.tsx";
import EventListView from "@/pages/events/s/EventListView.tsx"; import EventListView from "@/pages/events/s/EventListView.tsx";
import {useEventRights} from "@/pages/events/util.ts"; import {useEventRights} from "@/pages/events/util.ts";
import {useLogin} from "@/components/users/modals/hooks.ts"; import EventLoginWarning from "@/pages/events/s/EventLoginWarning.tsx";
export default function SharedEvent() { export default function SharedEvent() {
const {pb, user} = usePB() const {pb} = usePB()
const {eventId} = useParams() as { eventId: string } const {eventId} = useParams() as { eventId: string }
@ -43,9 +31,6 @@ export default function SharedEvent() {
const {isPrivilegedUser, canEditEvent} = useEventRights(eventQuery.data) const {isPrivilegedUser, canEditEvent} = useEventRights(eventQuery.data)
const {handler: loginHandler} = useLogin()
if (eventQuery.isLoading) { if (eventQuery.isLoading) {
return <Center h={"100%"}><Loader/></Center> return <Center h={"100%"}><Loader/></Center>
} }
@ -81,30 +66,7 @@ export default function SharedEvent() {
))}</Breadcrumbs> ))}</Breadcrumbs>
</div> </div>
{!user && <div className={"section-transparent"}> <EventLoginWarning/>
<Alert icon={
<ActionIcon
color={"green"}
variant={"transparent"}
onClick={loginHandler.open}
>
<IconLogin/>
</ActionIcon>
} color={"orange"}>
Um dich in eine Liste für dieses Event einzutragen, musst du dich
{" "}
<Text
onClick={loginHandler.open}
size={"sm"}
fw={700}
span
td={"underline"}
style={{cursor: "pointer"}}
>
anmelden oder einen Gastaccount erstellen
</Text>
</Alert>
</div>}
{eventIsArchived && <div className={"section-transparent"}> {eventIsArchived && <div className={"section-transparent"}>
<Alert color={"orange"} icon={<IconArchive/>}> <Alert color={"orange"} icon={<IconArchive/>}>