Projects CRUD (todo: project images)

This commit is contained in:
Ahmed Al-Taiar
2024-09-08 23:36:30 -04:00
parent 1c5a8d026a
commit e080e6b222
17 changed files with 386 additions and 251 deletions

View File

@ -5,7 +5,7 @@ export const schema = gql`
description: String!
images: [ProjectImage]!
date: DateTime!
links: [String]!
links: [URL]!
tags: [Tag]!
}
@ -18,14 +18,14 @@ export const schema = gql`
title: String!
description: String!
date: DateTime!
links: [String]!
links: [URL]!
}
input UpdateProjectInput {
title: String
description: String
date: DateTime
links: [String]!
links: [URL]!
}
type Mutation {

View File

@ -6,45 +6,35 @@ import type {
import { db } from 'src/lib/db'
export const projects: QueryResolvers['projects'] = () => {
return db.project.findMany()
}
export const projects: QueryResolvers['projects'] = () => db.project.findMany()
export const project: QueryResolvers['project'] = ({ id }) => {
return db.project.findUnique({
export const project: QueryResolvers['project'] = ({ id }) =>
db.project.findUnique({
where: { id },
})
}
export const createProject: MutationResolvers['createProject'] = ({
input,
}) => {
return db.project.create({
export const createProject: MutationResolvers['createProject'] = ({ input }) =>
db.project.create({
data: input,
})
}
export const updateProject: MutationResolvers['updateProject'] = ({
id,
input,
}) => {
return db.project.update({
}) =>
db.project.update({
data: input,
where: { id },
})
}
export const deleteProject: MutationResolvers['deleteProject'] = ({ id }) => {
return db.project.delete({
export const deleteProject: MutationResolvers['deleteProject'] = ({ id }) =>
db.project.delete({
where: { id },
})
}
export const Project: ProjectRelationResolvers = {
images: (_obj, { root }) => {
return db.project.findUnique({ where: { id: root?.id } }).images()
},
tags: (_obj, { root }) => {
return db.project.findUnique({ where: { id: root?.id } }).tags()
},
images: (_obj, { root }) =>
db.project.findUnique({ where: { id: root?.id } }).images(),
tags: (_obj, { root }) =>
db.project.findUnique({ where: { id: root?.id } }).tags(),
}

View File

@ -22,7 +22,7 @@ const ColorPicker = ({ color, setColor }: ColorPickerProps) => {
<HexColorInput color={color} className="w-16" />
</label>
<button
className="btn btn-square btn-sm "
className="btn btn-square btn-sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(color)

View File

@ -0,0 +1,68 @@
/* eslint-disable jsx-a11y/label-has-associated-control */
import { mdiDelete, mdiPlus } from '@mdi/js'
import Icon from '@mdi/react'
interface FormTextListProps {
name: string
itemPlaceholder: string
icon?: string
list: string[]
errors: boolean[]
setList: React.Dispatch<React.SetStateAction<string[]>>
}
const FormTextList = ({
name,
itemPlaceholder,
icon,
list,
errors,
setList,
}: FormTextListProps) => {
return (
<div className="flex flex-col space-y-2 bg-base-100 rounded-xl">
<div className="flex space-x-2 justify-between">
<div className="flex items-center">
<p className="font-semibold">{name}</p>
</div>
<button
className="btn btn-square btn-sm"
type="button"
onClick={() => setList([...list, ''])}
>
<Icon path={mdiPlus} className="size-4" />
</button>
</div>
{list.map((item, i) => (
<label
className={`input input-bordered pr-2 flex items-center space-x-2 w-full ${errors[i] && 'input-error'}`}
key={i}
>
<label
className={`size-4 flex-none ${errors[i] ? 'text-error' : 'opacity-70'}`}
>
<Icon path={icon} />
</label>
<input
type="text"
placeholder={itemPlaceholder}
className="flex-grow min-w-0"
value={item}
onChange={(e) =>
setList(list.map((val, j) => (j === i ? e.target.value : val)))
}
/>
<button
className="btn btn-square btn-sm flex-none"
type="button"
onClick={() => setList(list.filter((_, j) => j !== i))}
>
<Icon path={mdiDelete} className="size-4 text-error" />
</button>
</label>
))}
</div>
)
}
export default FormTextList

View File

@ -45,7 +45,6 @@ const UPDATE_PROJECT_MUTATION: TypedDocumentNode<
`
export const Loading = () => <CellLoading />
export const Failure = ({ error }: CellFailureProps) => (
<CellFailure error={error} />
)
@ -58,33 +57,37 @@ export const Success = ({ project }: CellSuccessProps<EditProjectById>) => {
toast.success('Project updated')
navigate(routes.projects())
},
onError: (error) => {
toast.error(error.message)
},
onError: (error) => toast.error(error.message),
}
)
const onSave = (
input: UpdateProjectInput,
id: EditProjectById['project']['id']
) => {
updateProject({ variables: { id, input } })
}
) => updateProject({ variables: { id, input } })
return (
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">
Edit Project {project?.id}
</h2>
</header>
<div className="rw-segment-main">
<ProjectForm
project={project}
onSave={onSave}
error={error}
loading={loading}
/>
<div className="flex w-full justify-center">
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
<table className="table w-80">
<thead className="bg-base-200 font-syne">
<tr>
<th className="w-0">Edit Project {project.id}</th>
</tr>
</thead>
<tbody>
<tr>
<th>
<ProjectForm
project={project}
onSave={onSave}
error={error}
loading={loading}
/>
</th>
</tr>
</tbody>
</table>
</div>
</div>
)

View File

@ -30,23 +30,30 @@ const NewProject = () => {
toast.success('Project created')
navigate(routes.projects())
},
onError: (error) => {
toast.error(error.message)
},
onError: (error) => toast.error(error.message),
}
)
const onSave = (input: CreateProjectInput) => {
const onSave = (input: CreateProjectInput) =>
createProject({ variables: { input } })
}
return (
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">New Project</h2>
</header>
<div className="rw-segment-main">
<ProjectForm onSave={onSave} loading={loading} error={error} />
<div className="flex w-full justify-center">
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
<table className="table w-80">
<thead className="bg-base-200 font-syne">
<tr>
<th>New Project</th>
</tr>
</thead>
<tbody>
<tr>
<th>
<ProjectForm onSave={onSave} error={error} loading={loading} />
</th>
</tr>
</tbody>
</table>
</div>
</div>
)

View File

@ -32,66 +32,76 @@ const Project = ({ project }: Props) => {
toast.success('Project deleted')
navigate(routes.projects())
},
onError: (error) => {
toast.error(error.message)
},
onError: (error) => toast.error(error.message),
})
const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => {
if (confirm('Are you sure you want to delete project ' + id + '?')) {
if (confirm('Are you sure you want to delete project ' + id + '?'))
deleteProject({ variables: { id } })
}
}
return (
<>
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">
Project {project.id} Detail
</h2>
</header>
<table className="rw-table">
<tbody>
<tr>
<th>Id</th>
<td>{project.id}</td>
</tr>
<tr>
<th>Title</th>
<td>{project.title}</td>
</tr>
<tr>
<th>Description</th>
<td>{project.description}</td>
</tr>
<tr>
<th>Date</th>
<td>{timeTag(project.date)}</td>
</tr>
<tr>
<th>Links</th>
<td>{project.links}</td>
</tr>
</tbody>
</table>
<div className="flex w-full justify-center">
<div>
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
<table className="table">
<thead className="bg-base-200 font-syne">
<tr>
<th className="w-0">
Project {project.id}: {project.title}
</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<tr>
<th>ID</th>
<td>{project.id}</td>
</tr>
<tr>
<th>Title</th>
<td>{project.title}</td>
</tr>
<tr>
<th>Description</th>
<td>{project.description}</td>
</tr>
<tr>
<th>Date</th>
<td>{timeTag(project.date)}</td>
</tr>
<tr>
<th>Links</th>
<td className="space-x-2 space-y-2">
{project.links.map((link, i) => (
<div className="badge badge-ghost text-nowrap" key={i}>
{link}
</div>
))}
</td>
</tr>
</tbody>
</table>
</div>
<nav className="my-2 flex justify-center space-x-2">
<Link
to={routes.editProject({ id: project.id })}
title={'Edit project ' + project.id}
className="btn btn-primary btn-sm uppercase"
>
Edit
</Link>
<button
type="button"
title={'Delete project ' + project.id}
className="btn btn-error btn-sm uppercase"
onClick={() => onDeleteClick(project.id)}
>
Delete
</button>
</nav>
</div>
<nav className="rw-button-group">
<Link
to={routes.editProject({ id: project.id })}
className="rw-button rw-button-blue"
>
Edit
</Link>
<button
type="button"
className="rw-button rw-button-red"
onClick={() => onDeleteClick(project.id)}
>
Delete
</button>
</nav>
</>
</div>
)
}

View File

@ -27,13 +27,10 @@ export const QUERY: TypedDocumentNode<
`
export const Loading = () => <CellLoading />
export const Empty = () => <CellEmpty />
export const Failure = ({
error,
}: CellFailureProps<FindProjectByIdVariables>) => <CellFailure error={error} />
export const Success = ({
project,
}: CellSuccessProps<FindProjectById, FindProjectByIdVariables>) => (

View File

@ -1,17 +1,26 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { mdiFormatTitle, mdiLinkVariant } from '@mdi/js'
import Icon from '@mdi/react'
import type { EditProjectById, UpdateProjectInput } from 'types/graphql'
import type { RWGqlError } from '@redwoodjs/forms'
import {
Form,
FormError,
FieldError,
Label,
TextField,
Submit,
TextAreaField,
} from '@redwoodjs/forms'
import { toast } from '@redwoodjs/web/toast'
import FormTextList from 'src/components/FormTextList/FormTextList'
type FormProject = NonNullable<EditProjectById['project']>
// TODO: add project images
interface ProjectFormProps {
project?: EditProjectById['project']
onSave: (data: UpdateProjectInput, id?: FormProject['id']) => void
@ -20,81 +29,110 @@ interface ProjectFormProps {
}
const ProjectForm = (props: ProjectFormProps) => {
const [links, setLinks] = useState<string[]>(props.project?.links || [])
const [linkErrors, setLinkErrors] = useState<boolean[]>([])
const urlRegex = useMemo(
() =>
/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i,
[]
)
useEffect(() => {
setLinkErrors(links.map((link) => link.length > 0 && !urlRegex.test(link)))
}, [links, urlRegex])
const onSubmit = (data: FormProject) => {
const errorsExist = linkErrors.indexOf(true) !== -1
const errorCount = linkErrors.filter((val) => val).length
const emptyCount = links.filter((val) => val.trim().length === 0).length
if (errorsExist) return toast.error(`${errorCount} links invalid`)
if (emptyCount > 0) return toast.error(`${emptyCount} links empty`)
data.links = links
data.date = new Date().toISOString() // TODO: change to date picker value
props.onSave(data, props?.project?.id)
}
const titleRef = useRef<HTMLInputElement>(null)
useEffect(() => titleRef.current?.focus(), [])
return (
<div className="rw-form-wrapper">
<Form<FormProject> onSubmit={onSubmit} error={props.error}>
<FormError
error={props.error}
wrapperClassName="rw-form-error-wrapper"
titleClassName="rw-form-error-title"
listClassName="rw-form-error-list"
/>
<Form<FormProject>
onSubmit={onSubmit}
error={props.error}
className="space-y-2 w-80"
>
<Label name="title" className="form-control w-full">
<Label
name="title"
className="rw-label"
errorClassName="rw-label rw-label-error"
className="input input-bordered flex items-center gap-2"
errorClassName="input input-bordered flex items-center gap-2 input-error"
>
Title
<Label
name="title"
className="size-4 opacity-70"
errorClassName="size-4 text-error"
>
<Icon path={mdiFormatTitle} />
</Label>
<TextField
name="title"
ref={titleRef}
placeholder="Title"
defaultValue={props.project?.title}
className="w-full"
validation={{
required: {
value: true,
message: 'Required',
},
}}
/>
</Label>
<div className="label">
<FieldError
name="title"
className="text-xs font-semibold text-error"
/>
</div>
</Label>
<TextField
name="title"
defaultValue={props.project?.title}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
/>
<FieldError name="title" className="rw-field-error" />
<Label
name="description"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Description
</Label>
<TextField
<Label name="description" className="form-control w-full">
<TextAreaField
name="description"
defaultValue={props.project?.description}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
className="textarea textarea-bordered"
errorClassName="textarea textarea-bordered textarea-error"
placeholder="Description"
/>
<FieldError name="description" className="rw-field-error" />
<Label
name="links"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Links
</Label>
<TextField
name="links"
defaultValue={props.project?.links}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
/>
<FieldError name="links" className="rw-field-error" />
<div className="rw-button-group">
<Submit disabled={props.loading} className="rw-button rw-button-blue">
Save
</Submit>
<div className="label">
<FieldError
name="description"
className="text-xs font-semibold text-error"
/>
</div>
</Form>
</div>
</Label>
<FormTextList
name="Links"
itemPlaceholder="URL"
icon={mdiLinkVariant}
list={links}
errors={linkErrors}
setList={setLinks}
/>
<nav className="my-2 flex justify-center space-x-2">
<Submit
disabled={props.loading}
className="btn btn-primary btn-sm uppercase"
>
Save
</Submit>
</nav>
</Form>
)
}

View File

@ -1,3 +1,5 @@
import { mdiDotsVertical } from '@mdi/js'
import Icon from '@mdi/react'
import type {
DeleteProjectMutation,
DeleteProjectMutationVariables,
@ -25,74 +27,97 @@ const DELETE_PROJECT_MUTATION: TypedDocumentNode<
const ProjectsList = ({ projects }: FindProjects) => {
const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, {
onCompleted: () => {
toast.success('Project deleted')
},
onError: (error) => {
toast.error(error.message)
},
// This refetches the query on the list page. Read more about other ways to
// update the cache over here:
// https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates
onCompleted: () => toast.success('Project deleted'),
onError: (error) => toast.error(error.message),
refetchQueries: [{ query: QUERY }],
awaitRefetchQueries: true,
})
const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => {
if (confirm('Are you sure you want to delete project ' + id + '?')) {
if (confirm('Are you sure you want to delete project ' + id + '?'))
deleteProject({ variables: { id } })
}
}
return (
<div className="rw-segment rw-table-wrapper-responsive">
<table className="rw-table">
<thead>
<div className="w-full overflow-hidden overflow-x-auto rounded-xl bg-base-100">
<table className="table">
<thead className="bg-base-200 font-syne">
<tr>
<th>Id</th>
<th>Title</th>
<th>Description</th>
<th>Date</th>
<th>Links</th>
<th>&nbsp;</th>
<th className="w-0">&nbsp;</th>
</tr>
</thead>
<tbody>
{projects.map((project) => (
<tr key={project.id}>
<td>{truncate(project.id)}</td>
<td>{truncate(project.title)}</td>
<td>{truncate(project.description)}</td>
<td>{timeTag(project.date)}</td>
<td>{truncate(project.links)}</td>
<td>
<nav className="rw-table-actions">
<Link
to={routes.project({ id: project.id })}
title={'Show project ' + project.id + ' detail'}
className="rw-button rw-button-small"
>
Show
</Link>
<Link
to={routes.editProject({ id: project.id })}
title={'Edit project ' + project.id}
className="rw-button rw-button-small rw-button-blue"
>
Edit
</Link>
<button
type="button"
title={'Delete project ' + project.id}
className="rw-button rw-button-small rw-button-red"
onClick={() => onDeleteClick(project.id)}
>
Delete
</button>
</nav>
</td>
</tr>
))}
{projects.map((project) => {
const actionButtons = (
<>
<Link
to={routes.project({ id: project.id })}
title={'Show project ' + project.id + ' detail'}
className="btn btn-xs uppercase"
>
Show
</Link>
<Link
to={routes.editProject({ id: project.id })}
title={'Edit project ' + project.id}
className="btn btn-primary btn-xs uppercase"
>
Edit
</Link>
<button
type="button"
title={'Delete projectt ' + project.id}
className="btn btn-error btn-xs uppercase"
onClick={() => onDeleteClick(project.id)}
>
Delete
</button>
</>
)
return (
<tr key={project.id}>
<td>{truncate(project.title)}</td>
<td>{truncate(project.description)}</td>
<td>{timeTag(project.date)}</td>
<td className="space-x-2 space-y-2">
{project.links.map((link, i) => (
<div className="badge badge-ghost text-nowrap" key={i}>
{link}
</div>
))}
</td>
<td>
<nav className="hidden justify-end space-x-2 sm:flex">
{actionButtons}
</nav>
<div className="dropdown dropdown-end flex justify-end sm:hidden">
<div
tabIndex={0}
role="button"
className="btn btn-square btn-ghost btn-sm lg:hidden"
>
<Icon
path={mdiDotsVertical}
className="text-base-content-100 size-6"
/>
</div>
<div
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
className="menu dropdown-content z-10 -mt-1 mr-10 inline rounded-box bg-base-100 shadow-xl"
>
<nav className="w-46 space-x-2">{actionButtons}</nav>
</div>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>

View File

@ -25,13 +25,10 @@ export const QUERY: TypedDocumentNode<FindProjects, FindProjectsVariables> =
`
export const Loading = () => <CellLoading />
export const Empty = () => <CellEmpty />
export const Failure = ({ error }: CellFailureProps<FindProjects>) => (
<CellFailure error={error} />
)
export const Success = ({
projects,
}: CellSuccessProps<FindProjects, FindProjectsVariables>) => (

View File

@ -44,9 +44,7 @@ const UPDATE_SOCIAL_MUTATION: TypedDocumentNode<
`
export const Loading = () => <CellLoading />
export const Empty = () => <CellEmpty />
export const Failure = ({ error }: CellFailureProps) => (
<CellFailure error={error} />
)
@ -77,17 +75,21 @@ export const Success = ({ social }: CellSuccessProps<EditSocialById>) => {
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
<table className="table w-80">
<thead className="bg-base-200 font-syne">
<th className="w-0">Edit Social {social.id}</th>
<tr>
<th className="w-0">Edit Social {social.id}</th>
</tr>
</thead>
<tbody>
<th>
<SocialForm
social={social}
onSave={onSave}
error={error}
loading={loading}
/>
</th>
<tr>
<th>
<SocialForm
social={social}
onSave={onSave}
error={error}
loading={loading}
/>
</th>
</tr>
</tbody>
</table>
</div>

View File

@ -32,9 +32,7 @@ const Social = ({ social }: Props) => {
toast.success('Social deleted')
navigate(routes.socials())
},
onError: (error) => {
toast.error(error.message)
},
onError: (error) => toast.error(error.message),
})
const onDeleteClick = (

View File

@ -86,8 +86,8 @@ const SocialForm = (props: SocialFormProps) => {
>
<Label
name="name"
className="h-4 w-4 opacity-70"
errorClassName="h-4 w-4 text-error"
className="size-4 opacity-70"
errorClassName="size-4 text-error"
>
<Icon path={mdiRename} />
</Label>

View File

@ -45,8 +45,8 @@ const TagForm = (props: TagFormProps) => {
>
<Label
name="tag"
className="h-4 w-4 opacity-70"
errorClassName="h-4 w-4 text-error"
className="size-4 opacity-70"
errorClassName="size-4 text-error"
>
<Icon path={mdiTag} />
</Label>

View File

@ -51,8 +51,8 @@ const ForgotPasswordPage = () => {
>
<Label
name="username"
className="h-4 w-4 opacity-70"
errorClassName="h-4 w-4 text-error"
className="size-4 opacity-70"
errorClassName="size-4 text-error"
>
<Icon path={mdiAccount} />
</Label>

View File

@ -75,8 +75,8 @@ const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => {
>
<Label
name="password"
className="h-4 w-4 opacity-70"
errorClassName="h-4 w-4 text-error"
className="size-4 opacity-70"
errorClassName="size-4 text-error"
>
<Icon path={mdiKey} />
</Label>