Skip to content

Commit

Permalink
Introduce contact table
Browse files Browse the repository at this point in the history
A new UI to mass create and update contacts.
  • Loading branch information
tordans committed Jan 22, 2025
1 parent a7d3f72 commit c1c6cd9
Show file tree
Hide file tree
Showing 7 changed files with 298 additions and 6 deletions.
45 changes: 45 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"postcss": "8.4.49",
"prisma": "5.22.0",
"react": "18.3.1",
"react-datasheet-grid": "4.11.4",
"react-dom": "18.3.1",
"react-hook-form": "7.53.2",
"react-intl": "6.8.9",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
"use client"
import { ObjectDump } from "@/src/app/admin/_components/ObjectDump"
import { improveErrorMessage } from "@/src/core/components/forms/improveErrorMessage"
import { pinkButtonStyles } from "@/src/core/components/links"
import { ButtonWrapper } from "@/src/core/components/links/ButtonWrapper"
import { useProjectSlug } from "@/src/core/routes/useProjectSlug"
import { isProduction } from "@/src/core/utils"
import createContact from "@/src/server/contacts/mutations/createContact"
import updateContact from "@/src/server/contacts/mutations/updateContact"
import getContacts, { TContacts } from "@/src/server/contacts/queries/getContacts"
import { getQueryClient, getQueryKey, useMutation, useQuery } from "@blitzjs/rpc"
import { useState } from "react"
import { Column, DataSheetGrid, keyColumn, textColumn } from "react-datasheet-grid"
import "react-datasheet-grid/dist/style.css"

type Row = {
id: string | null // Required
firstName: string | null
lastName: string | null // Required
email: string | null // Required
phone: string | null
note: string | null
role: string | null
}

export const ContactsTable = () => {
const projectSlug = useProjectSlug()

// To disable the automatic form refetching that useQuery does once we change something in the form.
const [formDirty, setFormDirty] = useState(false)
// This is hacky. We need to refetch the data after the mutation,
// which we do by invalidating the query and using onSucess (in usePaginatedQuery) to set the fresh data.
// However this breaks react during the first render, when `setData` is not present, yet.
// The workaround is to manually manage this state and only allow the update when we hit save.
const [performUpdate, setPerformUpdate] = useState(false)

const prepareData = (data: TContacts | undefined) => {
if (!data) return []
return data.contacts.map(({ id, firstName, lastName, email, phone, note, role }) => {
return { id: String(id), firstName, lastName, email, phone, note, role } satisfies Row
})
}

const queryKey = getQueryKey(getContacts, { projectSlug })
const [contacts] = useQuery(
getContacts,
{ projectSlug },
{
enabled: !formDirty,
onSuccess: (data) => {
if (performUpdate) {
setData(prepareData(data))
}
},
},
)

const [data, setData] = useState<Row[]>(prepareData(contacts))
const [errors, setErrors] = useState<[string, string][]>([])

const [createContactMutation, { isSuccess: isCreateSuccess }] = useMutation(createContact)
const [updateContactMutation, { isSuccess: isUpdateSuccess }] = useMutation(updateContact)

const NEW_ID_VALUE = "NEU"
const columns: Column<Row>[] = [
{
...keyColumn<Row, "id">("id", textColumn),
title: "ID",
disabled: true,
maxWidth: 60,
},
{
...keyColumn<Row, "firstName">("firstName", textColumn),
title: "Vorname",
},
{
...keyColumn<Row, "lastName">("lastName", textColumn),
title: "Nachname (Pflicht)",
},
{
...keyColumn<Row, "email">("email", textColumn),
title: "E-Mail-Adresse (Pflicht)",
},
{
...keyColumn<Row, "phone">("phone", textColumn),
title: "Telefonnummer",
},
{
...keyColumn<Row, "note">("note", textColumn),
title: "Notizen (Markdown)",
},
{
...keyColumn<Row, "role">("role", textColumn),
title: "Position",
},
]

const handleUpdate = async () => {
// Reset Errors
setErrors([])

// Only refetch, if all changes are stored.
// This allows us to manually show errors in the form and we can resubmit them.
let refetchData = false

for (const { id, lastName, email, ...value } of data) {
// Manually handle "required" fields errors
if (!lastName || !email) {
setErrors((prev) => [
...prev,
[String(id || NEW_ID_VALUE), "Nachname und E-Mail-Adresse sind Pflichtfelder."],
])
continue
}

const createOrUpdateMutation =
!id || id === NEW_ID_VALUE
? { action: createContactMutation, additionalData: {} }
: { action: updateContactMutation, additionalData: { id: Number(id) } }

await createOrUpdateMutation.action(
// @ts-expect-error TS is not able to infer that `id` should not be part of `create`
{ ...value, lastName, email, projectSlug, ...createOrUpdateMutation.additionalData },
{
onError: (error, updatedContacts, context) => {
refetchData = false
console.log("ERROR", error, updatedContacts, context)
setErrors((prev) => [
...prev,
[
String(id) || NEW_ID_VALUE,
improveErrorMessage(error, "FORM_ERROR", ["email"]) as any,
],
])
},
onSuccess: () => {
refetchData = true
},
},
)
}

if (refetchData) {
if (!isProduction) {
console.log("INFO", "update and create both where successfull, so refetching the data now")
}
setPerformUpdate(true)
setFormDirty(false)
await getQueryClient().invalidateQueries(queryKey)
}
}

return (
<>
<div className="mb-5 flex w-full items-start justify-between gap-5">
{errors.length > 0 ? (
<ul className="text-red-800">
{errors.map(([id, errors]) => {
const strings: string[] =
typeof errors === "object" ? Object.values(errors).filter(Boolean) : [errors]
return (
<li key={id} className="flex items-start gap-3">
<strong>ID {id}</strong>
<div>
{strings.map((string) => {
return <p key={string}>{string}</p>
})}
<ObjectDump data={{ id, errors }} />
</div>
</li>
)
})}
</ul>
) : (
<div />
)}

<ButtonWrapper className="justify-end">
{!formDirty && (isCreateSuccess || isUpdateSuccess) && (
<span className="text-green-500">Gespeichert</span>
)}
<button onClick={handleUpdate} className={pinkButtonStyles} disabled={!formDirty}>
Speichern
</button>
</ButtonWrapper>
</div>

<DataSheetGrid
value={data}
createRow={() => ({
id: NEW_ID_VALUE,
firstName: null,
lastName: null,
email: null,
phone: null,
note: null,
role: null,
})}
onChange={(values) => {
setFormDirty(true)
setData(values)
}}
columns={columns}
/>
</>
)
}
24 changes: 24 additions & 0 deletions src/app/(loggedInProjects)/[projectSlug]/contacts/table/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { PageHeader } from "@/src/core/components/pages/PageHeader"
import { Metadata } from "next"
import "server-only"
import { ContactsTable } from "./_components/ContactsTable"

export const metadata: Metadata = {
title: "Externe Kontakte bearbeiten & importieren",
}

export default async function ProjectContactsTablePage() {
return (
<>
<PageHeader
title="Externe Kontakte hinzufügen & bearbeiten"
description="Tipp: Kontakte können per Kopieren & Einfügen aus Excel übernommen werden."
className="mt-12"
/>
{/* We cannot use <Tabs> here because that is strongly tied to the pages router */}
{/* <Tabs className="mt-7" tabs={tabs} /> */}

<ContactsTable />
</>
)
}
13 changes: 12 additions & 1 deletion src/app/(loggedInProjects)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useAuthenticatedBlitzContext } from "@/src/blitz-server"
import type { Metadata } from "next"
import { FooterMinimal } from "../_components/layouts/footer/FooterMinimal"
import { NavigationLoggedInProject } from "../_components/layouts/navigation/NavigationLoggedInProject"

export const metadata: Metadata = {
robots: "noindex",
Expand All @@ -12,5 +14,14 @@ export const metadata: Metadata = {
export default async function LoggedInProjectsLayout({ children }: { children: React.ReactNode }) {
await useAuthenticatedBlitzContext({ redirectTo: "/auth/login" })

return <>{children}</>
return (
<>
<div className="relative flex h-full flex-col overflow-x-hidden">
<NavigationLoggedInProject />
<main className="mx-auto w-full max-w-7xl px-6 pb-16 md:px-8">{children}</main>
</div>
{/* <FooterProject /> <-- get nicht, da es auf pages directory helper zurück greift */}
<FooterMinimal />
</>
)
}
11 changes: 6 additions & 5 deletions src/pagesComponents/contacts/ContactTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import getProject from "@/src/server/projects/queries/getProject"
import { useCurrentUser } from "@/src/server/users/hooks/useCurrentUser"
import { Routes } from "@blitzjs/next"
import { useQuery } from "@blitzjs/rpc"
import { PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid"
import { TrashIcon } from "@heroicons/react/20/solid"
import { Contact } from "@prisma/client"
import { useRouter } from "next/router"
import { useState } from "react"
Expand Down Expand Up @@ -105,7 +105,8 @@ export const ContactTable = ({ contacts }: Props) => {
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<div className="flex items-center justify-end gap-4 text-right">
<IfUserCanEdit>
{/* Disabled in favor of the contacts/table UI */}
{/* <IfUserCanEdit>
<Link
href={Routes.EditContactPage({
contactId: contact.id,
Expand All @@ -115,7 +116,7 @@ export const ContactTable = ({ contacts }: Props) => {
<PencilSquareIcon className="h-4 w-4" />
<span className="sr-only">Bearbeiten</span>
</Link>
</IfUserCanEdit>
</IfUserCanEdit> */}
<Link
href={Routes.ShowContactPage({
contactId: contact.id,
Expand All @@ -140,8 +141,8 @@ export const ContactTable = ({ contacts }: Props) => {

<ButtonWrapper className="mt-6 justify-between">
<IfUserCanEdit>
<Link button="blue" icon="plus" href={Routes.NewContactPage({ projectSlug })}>
Kontakt
<Link button="blue" icon="plus" href="contacts/table">
Kontakte hinzufügen & bearbeiten
</Link>
</IfUserCanEdit>
<button disabled={!mailButtonActive} className={whiteButtonStyles} type="submit">
Expand Down
3 changes: 3 additions & 0 deletions src/server/contacts/queries/getContacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { viewerRoles } from "@/src/authorization/constants"
import { extractProjectSlug } from "@/src/authorization/extractProjectSlug"
import { resolver } from "@blitzjs/rpc"
import { paginate } from "blitz"
import getContacts from "./getContacts"

export type TContacts = Awaited<ReturnType<typeof getContacts>>

type GetContactsInput = { projectSlug: string } & Pick<
Prisma.ContactFindManyArgs,
Expand Down

0 comments on commit c1c6cd9

Please sign in to comment.