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

View File

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

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

View File

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

View File

@@ -32,29 +32,30 @@ const Project = ({ project }: Props) => {
toast.success('Project deleted') toast.success('Project deleted')
navigate(routes.projects()) navigate(routes.projects())
}, },
onError: (error) => { onError: (error) => toast.error(error.message),
toast.error(error.message)
},
}) })
const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => { 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 } }) deleteProject({ variables: { id } })
} }
}
return ( return (
<> <div className="flex w-full justify-center">
<div className="rw-segment"> <div>
<header className="rw-segment-header"> <div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
<h2 className="rw-heading rw-heading-secondary"> <table className="table">
Project {project.id} Detail <thead className="bg-base-200 font-syne">
</h2> <tr>
</header> <th className="w-0">
<table className="rw-table"> Project {project.id}: {project.title}
</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody> <tbody>
<tr> <tr>
<th>Id</th> <th>ID</th>
<td>{project.id}</td> <td>{project.id}</td>
</tr> </tr>
<tr> <tr>
@@ -71,27 +72,36 @@ const Project = ({ project }: Props) => {
</tr> </tr>
<tr> <tr>
<th>Links</th> <th>Links</th>
<td>{project.links}</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>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<nav className="rw-button-group"> <nav className="my-2 flex justify-center space-x-2">
<Link <Link
to={routes.editProject({ id: project.id })} to={routes.editProject({ id: project.id })}
className="rw-button rw-button-blue" title={'Edit project ' + project.id}
className="btn btn-primary btn-sm uppercase"
> >
Edit Edit
</Link> </Link>
<button <button
type="button" type="button"
className="rw-button rw-button-red" title={'Delete project ' + project.id}
className="btn btn-error btn-sm uppercase"
onClick={() => onDeleteClick(project.id)} onClick={() => onDeleteClick(project.id)}
> >
Delete Delete
</button> </button>
</nav> </nav>
</> </div>
</div>
) )
} }

View File

@@ -27,13 +27,10 @@ export const QUERY: TypedDocumentNode<
` `
export const Loading = () => <CellLoading /> export const Loading = () => <CellLoading />
export const Empty = () => <CellEmpty /> export const Empty = () => <CellEmpty />
export const Failure = ({ export const Failure = ({
error, error,
}: CellFailureProps<FindProjectByIdVariables>) => <CellFailure error={error} /> }: CellFailureProps<FindProjectByIdVariables>) => <CellFailure error={error} />
export const Success = ({ export const Success = ({
project, project,
}: CellSuccessProps<FindProjectById, FindProjectByIdVariables>) => ( }: 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 { EditProjectById, UpdateProjectInput } from 'types/graphql'
import type { RWGqlError } from '@redwoodjs/forms' import type { RWGqlError } from '@redwoodjs/forms'
import { import {
Form, Form,
FormError,
FieldError, FieldError,
Label, Label,
TextField, TextField,
Submit, Submit,
TextAreaField,
} from '@redwoodjs/forms' } from '@redwoodjs/forms'
import { toast } from '@redwoodjs/web/toast'
import FormTextList from 'src/components/FormTextList/FormTextList'
type FormProject = NonNullable<EditProjectById['project']> type FormProject = NonNullable<EditProjectById['project']>
// TODO: add project images
interface ProjectFormProps { interface ProjectFormProps {
project?: EditProjectById['project'] project?: EditProjectById['project']
onSave: (data: UpdateProjectInput, id?: FormProject['id']) => void onSave: (data: UpdateProjectInput, id?: FormProject['id']) => void
@@ -20,81 +29,110 @@ interface ProjectFormProps {
} }
const ProjectForm = (props: 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 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) props.onSave(data, props?.project?.id)
} }
const titleRef = useRef<HTMLInputElement>(null)
useEffect(() => titleRef.current?.focus(), [])
return ( return (
<div className="rw-form-wrapper"> <Form<FormProject>
<Form<FormProject> onSubmit={onSubmit} error={props.error}> onSubmit={onSubmit}
<FormError
error={props.error} error={props.error}
wrapperClassName="rw-form-error-wrapper" className="space-y-2 w-80"
titleClassName="rw-form-error-title" >
listClassName="rw-form-error-list" <Label name="title" className="form-control w-full">
/>
<Label <Label
name="title" name="title"
className="rw-label" className="input input-bordered flex items-center gap-2"
errorClassName="rw-label rw-label-error" 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> </Label>
<TextField <TextField
name="title" name="title"
ref={titleRef}
placeholder="Title"
defaultValue={props.project?.title} defaultValue={props.project?.title}
className="rw-input" className="w-full"
errorClassName="rw-input rw-input-error" validation={{
validation={{ required: true }} required: {
value: true,
message: 'Required',
},
}}
/> />
</Label>
<FieldError name="title" className="rw-field-error" /> <div className="label">
<FieldError
<Label name="title"
name="description" className="text-xs font-semibold text-error"
className="rw-label" />
errorClassName="rw-label rw-label-error" </div>
>
Description
</Label> </Label>
<TextField <Label name="description" className="form-control w-full">
<TextAreaField
name="description" name="description"
defaultValue={props.project?.description} defaultValue={props.project?.description}
className="rw-input" className="textarea textarea-bordered"
errorClassName="rw-input rw-input-error" errorClassName="textarea textarea-bordered textarea-error"
validation={{ required: true }} placeholder="Description"
/> />
<div className="label">
<FieldError name="description" className="rw-field-error" /> <FieldError
name="description"
<Label className="text-xs font-semibold text-error"
name="links" />
className="rw-label" </div>
errorClassName="rw-label rw-label-error"
>
Links
</Label> </Label>
<TextField <FormTextList
name="links" name="Links"
defaultValue={props.project?.links} itemPlaceholder="URL"
className="rw-input" icon={mdiLinkVariant}
errorClassName="rw-input rw-input-error" list={links}
validation={{ required: true }} errors={linkErrors}
setList={setLinks}
/> />
<FieldError name="links" className="rw-field-error" /> <nav className="my-2 flex justify-center space-x-2">
<Submit
<div className="rw-button-group"> disabled={props.loading}
<Submit disabled={props.loading} className="rw-button rw-button-blue"> className="btn btn-primary btn-sm uppercase"
>
Save Save
</Submit> </Submit>
</div> </nav>
</Form> </Form>
</div>
) )
} }

View File

@@ -1,3 +1,5 @@
import { mdiDotsVertical } from '@mdi/js'
import Icon from '@mdi/react'
import type { import type {
DeleteProjectMutation, DeleteProjectMutation,
DeleteProjectMutationVariables, DeleteProjectMutationVariables,
@@ -25,74 +27,97 @@ const DELETE_PROJECT_MUTATION: TypedDocumentNode<
const ProjectsList = ({ projects }: FindProjects) => { const ProjectsList = ({ projects }: FindProjects) => {
const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, { const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, {
onCompleted: () => { onCompleted: () => toast.success('Project deleted'),
toast.success('Project deleted') onError: (error) => toast.error(error.message),
},
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
refetchQueries: [{ query: QUERY }], refetchQueries: [{ query: QUERY }],
awaitRefetchQueries: true, awaitRefetchQueries: true,
}) })
const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => { 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 } }) deleteProject({ variables: { id } })
} }
}
return ( return (
<div className="rw-segment rw-table-wrapper-responsive"> <div className="w-full overflow-hidden overflow-x-auto rounded-xl bg-base-100">
<table className="rw-table"> <table className="table">
<thead> <thead className="bg-base-200 font-syne">
<tr> <tr>
<th>Id</th>
<th>Title</th> <th>Title</th>
<th>Description</th> <th>Description</th>
<th>Date</th> <th>Date</th>
<th>Links</th> <th>Links</th>
<th>&nbsp;</th> <th className="w-0">&nbsp;</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{projects.map((project) => ( {projects.map((project) => {
<tr key={project.id}> const actionButtons = (
<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 <Link
to={routes.project({ id: project.id })} to={routes.project({ id: project.id })}
title={'Show project ' + project.id + ' detail'} title={'Show project ' + project.id + ' detail'}
className="rw-button rw-button-small" className="btn btn-xs uppercase"
> >
Show Show
</Link> </Link>
<Link <Link
to={routes.editProject({ id: project.id })} to={routes.editProject({ id: project.id })}
title={'Edit project ' + project.id} title={'Edit project ' + project.id}
className="rw-button rw-button-small rw-button-blue" className="btn btn-primary btn-xs uppercase"
> >
Edit Edit
</Link> </Link>
<button <button
type="button" type="button"
title={'Delete project ' + project.id} title={'Delete projectt ' + project.id}
className="rw-button rw-button-small rw-button-red" className="btn btn-error btn-xs uppercase"
onClick={() => onDeleteClick(project.id)} onClick={() => onDeleteClick(project.id)}
> >
Delete Delete
</button> </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> </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> </td>
</tr> </tr>
))} )
})}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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