Parts schema and scaffolld, with file uploading for the image (through Filestack)
This commit is contained in:
@ -17,3 +17,5 @@ PRISMA_HIDE_UPDATE_MESSAGE=true
|
||||
# Most applications want "debug" or "info" during dev, "trace" when you have issues and "warn" in production.
|
||||
# Ordered by how verbose they are: trace | debug | info | warn | error | silent
|
||||
# LOG_LEVEL=debug
|
||||
REDWOOD_ENV_FILESTACK_API_KEY=
|
||||
REDWOOD_ENV_FILESTACK_SECRET=
|
||||
|
@ -2,3 +2,5 @@
|
||||
# TEST_DATABASE_URL=file:./.redwood/test.db
|
||||
# PRISMA_HIDE_UPDATE_MESSAGE=true
|
||||
# LOG_LEVEL=trace
|
||||
REDWOOD_ENV_FILESTACK_API_KEY=
|
||||
REDWOOD_ENV_FILESTACK_SECRET=
|
||||
|
@ -0,0 +1,9 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Part" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT DEFAULT 'No description provided',
|
||||
"availableStock" INTEGER NOT NULL DEFAULT 0,
|
||||
"imageUrl" TEXT NOT NULL DEFAULT '/no_image.png',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
3
api/db/migrations/migration_lock.toml
Normal file
3
api/db/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
@ -8,11 +8,11 @@ generator client {
|
||||
binaryTargets = "native"
|
||||
}
|
||||
|
||||
// Define your own datamodels here and run `yarn redwood prisma migrate dev`
|
||||
// to create migrations for them and apply to your dev DB.
|
||||
// TODO: Please remove the following example:
|
||||
model UserExample {
|
||||
model Part {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
name String?
|
||||
name String
|
||||
description String? @default("No description provided")
|
||||
availableStock Int @default(0)
|
||||
imageUrl String @default("/no_image.png")
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@redwoodjs/api": "6.3.2",
|
||||
"@redwoodjs/graphql-server": "6.3.2"
|
||||
"@redwoodjs/graphql-server": "6.3.2",
|
||||
"filestack-js": "^3.27.0"
|
||||
}
|
||||
}
|
||||
|
35
api/src/graphql/parts.sdl.ts
Normal file
35
api/src/graphql/parts.sdl.ts
Normal file
@ -0,0 +1,35 @@
|
||||
export const schema = gql`
|
||||
type Part {
|
||||
id: Int!
|
||||
name: String!
|
||||
description: String
|
||||
availableStock: Int!
|
||||
imageUrl: String!
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
||||
type Query {
|
||||
parts: [Part!]! @requireAuth
|
||||
part(id: Int!): Part @requireAuth
|
||||
}
|
||||
|
||||
input CreatePartInput {
|
||||
name: String!
|
||||
description: String
|
||||
availableStock: Int!
|
||||
imageUrl: String!
|
||||
}
|
||||
|
||||
input UpdatePartInput {
|
||||
name: String
|
||||
description: String
|
||||
availableStock: Int
|
||||
imageUrl: String
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createPart(input: CreatePartInput!): Part! @requireAuth
|
||||
updatePart(id: Int!, input: UpdatePartInput!): Part! @requireAuth
|
||||
deletePart(id: Int!): Part! @requireAuth
|
||||
}
|
||||
`
|
11
api/src/services/parts/parts.scenarios.ts
Normal file
11
api/src/services/parts/parts.scenarios.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { Prisma, Part } from '@prisma/client'
|
||||
import type { ScenarioData } from '@redwoodjs/testing/api'
|
||||
|
||||
export const standard = defineScenario<Prisma.PartCreateArgs>({
|
||||
part: {
|
||||
one: { data: { name: 'String' } },
|
||||
two: { data: { name: 'String' } },
|
||||
},
|
||||
})
|
||||
|
||||
export type StandardScenario = ScenarioData<Part, 'part'>
|
49
api/src/services/parts/parts.test.ts
Normal file
49
api/src/services/parts/parts.test.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import type { Part } from '@prisma/client'
|
||||
|
||||
import { parts, part, createPart, updatePart, deletePart } from './parts'
|
||||
import type { StandardScenario } from './parts.scenarios'
|
||||
|
||||
// Generated boilerplate tests do not account for all circumstances
|
||||
// and can fail without adjustments, e.g. Float.
|
||||
// Please refer to the RedwoodJS Testing Docs:
|
||||
// https://redwoodjs.com/docs/testing#testing-services
|
||||
// https://redwoodjs.com/docs/testing#jest-expect-type-considerations
|
||||
|
||||
describe('parts', () => {
|
||||
scenario('returns all parts', async (scenario: StandardScenario) => {
|
||||
const result = await parts()
|
||||
|
||||
expect(result.length).toEqual(Object.keys(scenario.part).length)
|
||||
})
|
||||
|
||||
scenario('returns a single part', async (scenario: StandardScenario) => {
|
||||
const result = await part({ id: scenario.part.one.id })
|
||||
|
||||
expect(result).toEqual(scenario.part.one)
|
||||
})
|
||||
|
||||
scenario('creates a part', async () => {
|
||||
const result = await createPart({
|
||||
input: { name: 'String' },
|
||||
})
|
||||
|
||||
expect(result.name).toEqual('String')
|
||||
})
|
||||
|
||||
scenario('updates a part', async (scenario: StandardScenario) => {
|
||||
const original = (await part({ id: scenario.part.one.id })) as Part
|
||||
const result = await updatePart({
|
||||
id: original.id,
|
||||
input: { name: 'String2' },
|
||||
})
|
||||
|
||||
expect(result.name).toEqual('String2')
|
||||
})
|
||||
|
||||
scenario('deletes a part', async (scenario: StandardScenario) => {
|
||||
const original = (await deletePart({ id: scenario.part.one.id })) as Part
|
||||
const result = await part({ id: original.id })
|
||||
|
||||
expect(result).toEqual(null)
|
||||
})
|
||||
})
|
48
api/src/services/parts/parts.ts
Normal file
48
api/src/services/parts/parts.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import * as Filestack from 'filestack-js'
|
||||
import type { QueryResolvers, MutationResolvers } from 'types/graphql'
|
||||
|
||||
import { db } from 'src/lib/db'
|
||||
|
||||
export const parts: QueryResolvers['parts'] = () => {
|
||||
return db.part.findMany()
|
||||
}
|
||||
|
||||
export const part: QueryResolvers['part'] = ({ id }) => {
|
||||
return db.part.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export const createPart: MutationResolvers['createPart'] = ({ input }) => {
|
||||
return db.part.create({
|
||||
data: input,
|
||||
})
|
||||
}
|
||||
|
||||
export const updatePart: MutationResolvers['updatePart'] = ({ id, input }) => {
|
||||
return db.part.update({
|
||||
data: input,
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export const deletePart: MutationResolvers['deletePart'] = async ({ id }) => {
|
||||
const client = Filestack.init(process.env.REDWOOD_ENV_FILESTACK_API_KEY)
|
||||
const part = await db.part.findUnique({ where: { id } })
|
||||
const handle = part.imageUrl.split('/').pop()
|
||||
|
||||
const security = Filestack.getSecurity(
|
||||
{
|
||||
expiry: new Date().getTime() + 5 * 60 * 1000,
|
||||
handle,
|
||||
call: ['remove'],
|
||||
},
|
||||
process.env.REDWOOD_ENV_FILESTACK_SECRET
|
||||
)
|
||||
|
||||
await client.remove(handle, security)
|
||||
|
||||
return db.part.delete({
|
||||
where: { id },
|
||||
})
|
||||
}
|
@ -16,6 +16,8 @@
|
||||
"@redwoodjs/forms": "6.3.2",
|
||||
"@redwoodjs/router": "6.3.2",
|
||||
"@redwoodjs/web": "6.3.2",
|
||||
"filestack-react": "^4.0.1",
|
||||
"humanize-string": "2.1.0",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
@ -23,6 +25,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redwoodjs/vite": "6.3.2",
|
||||
"@types/filestack-react": "^4.0.3",
|
||||
"@types/node": "^20.8.9",
|
||||
"@types/react": "18.2.14",
|
||||
"@types/react-dom": "18.2.6",
|
||||
"autoprefixer": "^10.4.16",
|
||||
|
BIN
web/public/no_image.png
Normal file
BIN
web/public/no_image.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
@ -4,6 +4,7 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
|
||||
import FatalErrorPage from 'src/pages/FatalErrorPage'
|
||||
import Routes from 'src/Routes'
|
||||
|
||||
import './scaffold.css'
|
||||
import './index.css'
|
||||
|
||||
const App = () => (
|
||||
|
@ -10,10 +10,17 @@
|
||||
import { Router, Route, Set } from '@redwoodjs/router'
|
||||
|
||||
import NavbarLayout from 'src/layouts/NavbarLayout'
|
||||
import ScaffoldLayout from 'src/layouts/ScaffoldLayout'
|
||||
|
||||
const Routes = () => {
|
||||
return (
|
||||
<Router>
|
||||
<Set wrap={ScaffoldLayout} title="Parts" titleTo="parts" buttonLabel="New Part" buttonTo="newPart">
|
||||
<Route path="/admin/parts/new" page={PartNewPartPage} name="newPart" />
|
||||
<Route path="/admin/parts/{id:Int}/edit" page={PartEditPartPage} name="editPart" />
|
||||
<Route path="/admin/parts/{id:Int}" page={PartPartPage} name="part" />
|
||||
<Route path="/admin/parts" page={PartPartsPage} name="parts" />
|
||||
</Set>
|
||||
<Set wrap={NavbarLayout}>
|
||||
<Route path="/" page={HomePage} name="home" />
|
||||
</Set>
|
||||
|
68
web/src/components/Part/EditPartCell/EditPartCell.tsx
Normal file
68
web/src/components/Part/EditPartCell/EditPartCell.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import type { EditPartById, UpdatePartInput } from 'types/graphql'
|
||||
|
||||
import { navigate, routes } from '@redwoodjs/router'
|
||||
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
|
||||
import { useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import PartForm from 'src/components/Part/PartForm'
|
||||
|
||||
export const QUERY = gql`
|
||||
query EditPartById($id: Int!) {
|
||||
part: part(id: $id) {
|
||||
id
|
||||
name
|
||||
description
|
||||
availableStock
|
||||
imageUrl
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`
|
||||
const UPDATE_PART_MUTATION = gql`
|
||||
mutation UpdatePartMutation($id: Int!, $input: UpdatePartInput!) {
|
||||
updatePart(id: $id, input: $input) {
|
||||
id
|
||||
name
|
||||
description
|
||||
availableStock
|
||||
imageUrl
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Failure = ({ error }: CellFailureProps) => (
|
||||
<div className="rw-cell-error">{error?.message}</div>
|
||||
)
|
||||
|
||||
export const Success = ({ part }: CellSuccessProps<EditPartById>) => {
|
||||
const [updatePart, { loading, error }] = useMutation(UPDATE_PART_MUTATION, {
|
||||
onCompleted: () => {
|
||||
toast.success('Part updated')
|
||||
navigate(routes.parts())
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const onSave = (input: UpdatePartInput, id: EditPartById['part']['id']) => {
|
||||
updatePart({ variables: { id, input } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rw-segment">
|
||||
<header className="rw-segment-header">
|
||||
<h2 className="rw-heading rw-heading-secondary">
|
||||
Edit Part {part?.id}
|
||||
</h2>
|
||||
</header>
|
||||
<div className="rw-segment-main">
|
||||
<PartForm part={part} onSave={onSave} error={error} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
44
web/src/components/Part/NewPart/NewPart.tsx
Normal file
44
web/src/components/Part/NewPart/NewPart.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { navigate, routes } from '@redwoodjs/router'
|
||||
import { useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import PartForm from 'src/components/Part/PartForm'
|
||||
|
||||
import type { CreatePartInput } from 'types/graphql'
|
||||
|
||||
const CREATE_PART_MUTATION = gql`
|
||||
mutation CreatePartMutation($input: CreatePartInput!) {
|
||||
createPart(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const NewPart = () => {
|
||||
const [createPart, { loading, error }] = useMutation(CREATE_PART_MUTATION, {
|
||||
onCompleted: () => {
|
||||
toast.success('Part created')
|
||||
navigate(routes.parts())
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const onSave = (input: CreatePartInput) => {
|
||||
createPart({ variables: { input } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rw-segment">
|
||||
<header className="rw-segment-header">
|
||||
<h2 className="rw-heading rw-heading-secondary">New Part</h2>
|
||||
</header>
|
||||
<div className="rw-segment-main">
|
||||
<PartForm onSave={onSave} loading={loading} error={error} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewPart
|
94
web/src/components/Part/Part/Part.tsx
Normal file
94
web/src/components/Part/Part/Part.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import type { DeletePartMutationVariables, FindPartById } from 'types/graphql'
|
||||
|
||||
import { Link, routes, navigate } from '@redwoodjs/router'
|
||||
import { useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import { timeTag } from 'src/lib/formatters'
|
||||
|
||||
const DELETE_PART_MUTATION = gql`
|
||||
mutation DeletePartMutation($id: Int!) {
|
||||
deletePart(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface Props {
|
||||
part: NonNullable<FindPartById['part']>
|
||||
}
|
||||
|
||||
const Part = ({ part }: Props) => {
|
||||
const [deletePart] = useMutation(DELETE_PART_MUTATION, {
|
||||
onCompleted: () => {
|
||||
toast.success('Part deleted')
|
||||
navigate(routes.parts())
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const onDeleteClick = (id: DeletePartMutationVariables['id']) => {
|
||||
if (confirm('Are you sure you want to delete part ' + id + '?')) {
|
||||
deletePart({ variables: { id } })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rw-segment">
|
||||
<header className="rw-segment-header">
|
||||
<h2 className="rw-heading rw-heading-secondary">
|
||||
Part {part.id} Detail
|
||||
</h2>
|
||||
</header>
|
||||
<table className="rw-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<td>{part.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<td>{part.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<td>{part.description}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Available stock</th>
|
||||
<td>{part.availableStock}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<td>{part.imageUrl}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Created at</th>
|
||||
<td>{timeTag(part.createdAt)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<nav className="rw-button-group">
|
||||
<Link
|
||||
to={routes.editPart({ id: part.id })}
|
||||
className="rw-button rw-button-blue"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="rw-button rw-button-red"
|
||||
onClick={() => onDeleteClick(part.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Part
|
30
web/src/components/Part/PartCell/PartCell.tsx
Normal file
30
web/src/components/Part/PartCell/PartCell.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import type { FindPartById } from 'types/graphql'
|
||||
|
||||
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
|
||||
|
||||
import Part from 'src/components/Part/Part'
|
||||
|
||||
export const QUERY = gql`
|
||||
query FindPartById($id: Int!) {
|
||||
part: part(id: $id) {
|
||||
id
|
||||
name
|
||||
description
|
||||
availableStock
|
||||
imageUrl
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => <div>Part not found</div>
|
||||
|
||||
export const Failure = ({ error }: CellFailureProps) => (
|
||||
<div className="rw-cell-error">{error?.message}</div>
|
||||
)
|
||||
|
||||
export const Success = ({ part }: CellSuccessProps<FindPartById>) => {
|
||||
return <Part part={part} />
|
||||
}
|
153
web/src/components/Part/PartForm/PartForm.tsx
Normal file
153
web/src/components/Part/PartForm/PartForm.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { PickerInline } from 'filestack-react'
|
||||
import type { EditPartById, UpdatePartInput } from 'types/graphql'
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormError,
|
||||
FieldError,
|
||||
Label,
|
||||
TextField,
|
||||
NumberField,
|
||||
Submit,
|
||||
} from '@redwoodjs/forms'
|
||||
import type { RWGqlError } from '@redwoodjs/forms'
|
||||
|
||||
type FormPart = NonNullable<EditPartById['part']>
|
||||
|
||||
interface PartFormProps {
|
||||
part?: EditPartById['part']
|
||||
onSave: (data: UpdatePartInput, id?: FormPart['id']) => void
|
||||
error: RWGqlError
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const PartForm = (props: PartFormProps) => {
|
||||
const [imageUrl, setImageUrl] = useState(props?.part?.imageUrl)
|
||||
|
||||
const onSubmit = (data: FormPart) => {
|
||||
const dataWithImageUrl = Object.assign(data, { imageUrl })
|
||||
props.onSave(dataWithImageUrl, props?.part?.id)
|
||||
}
|
||||
|
||||
const onImageUpload = (response) => {
|
||||
setImageUrl(response.filesUploaded[0].url)
|
||||
}
|
||||
|
||||
const preview = (url: string) => {
|
||||
const parts = url.split('/')
|
||||
parts.splice(3, 0, 'resize=height:500')
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rw-form-wrapper">
|
||||
<Form<FormPart> onSubmit={onSubmit} error={props.error}>
|
||||
<FormError
|
||||
error={props.error}
|
||||
wrapperClassName="rw-form-error-wrapper"
|
||||
titleClassName="rw-form-error-title"
|
||||
listClassName="rw-form-error-list"
|
||||
/>
|
||||
|
||||
<Label
|
||||
name="name"
|
||||
className="rw-label"
|
||||
errorClassName="rw-label rw-label-error"
|
||||
>
|
||||
Name
|
||||
</Label>
|
||||
|
||||
<TextField
|
||||
name="name"
|
||||
defaultValue={props.part?.name}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
/>
|
||||
|
||||
<FieldError name="name" className="rw-field-error" />
|
||||
|
||||
<Label
|
||||
name="description"
|
||||
className="rw-label"
|
||||
errorClassName="rw-label rw-label-error"
|
||||
>
|
||||
Description
|
||||
</Label>
|
||||
|
||||
<TextField
|
||||
name="description"
|
||||
defaultValue={props.part?.description}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
/>
|
||||
|
||||
<FieldError name="description" className="rw-field-error" />
|
||||
|
||||
<Label
|
||||
name="availableStock"
|
||||
className="rw-label"
|
||||
errorClassName="rw-label rw-label-error"
|
||||
>
|
||||
Available stock
|
||||
</Label>
|
||||
|
||||
<NumberField
|
||||
name="availableStock"
|
||||
defaultValue={props.part?.availableStock}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
/>
|
||||
|
||||
<FieldError name="availableStock" className="rw-field-error" />
|
||||
|
||||
<Label
|
||||
name="imageUrl"
|
||||
className="rw-label"
|
||||
errorClassName="rw-label rw-label-error"
|
||||
>
|
||||
Image
|
||||
</Label>
|
||||
|
||||
{!imageUrl && (
|
||||
<div style={{ height: '500px' }}>
|
||||
<PickerInline
|
||||
onSuccess={onImageUpload}
|
||||
pickerOptions={{ accept: 'image/*' }}
|
||||
apikey={process.env.REDWOOD_ENV_FILESTACK_API_KEY}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageUrl && (
|
||||
<div>
|
||||
<img
|
||||
alt=""
|
||||
src={preview(imageUrl)}
|
||||
style={{ display: 'block', margin: '2rem 0' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setImageUrl(null)}
|
||||
className="rw-button rw-button-blue"
|
||||
>
|
||||
Replace Image
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FieldError name="imageUrl" className="rw-field-error" />
|
||||
|
||||
<div className="rw-button-group">
|
||||
<Submit disabled={props.loading} className="rw-button rw-button-blue">
|
||||
Save
|
||||
</Submit>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PartForm
|
110
web/src/components/Part/Parts/Parts.tsx
Normal file
110
web/src/components/Part/Parts/Parts.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import type { DeletePartMutationVariables, FindParts } from 'types/graphql'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import { useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import { QUERY } from 'src/components/Part/PartsCell'
|
||||
import { timeTag, truncate } from 'src/lib/formatters'
|
||||
|
||||
const DELETE_PART_MUTATION = gql`
|
||||
mutation DeletePartMutation($id: Int!) {
|
||||
deletePart(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const PartsList = ({ parts }: FindParts) => {
|
||||
const [deletePart] = useMutation(DELETE_PART_MUTATION, {
|
||||
onCompleted: () => {
|
||||
toast.success('Part 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
|
||||
refetchQueries: [{ query: QUERY }],
|
||||
awaitRefetchQueries: true,
|
||||
})
|
||||
|
||||
const onDeleteClick = (id: DeletePartMutationVariables['id']) => {
|
||||
if (confirm('Are you sure you want to delete part ' + id + '?')) {
|
||||
deletePart({ variables: { id } })
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnail = (url: string) => {
|
||||
const parts = url.split('/')
|
||||
parts.splice(3, 0, 'resize=width:100')
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rw-segment rw-table-wrapper-responsive">
|
||||
<table className="rw-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Available stock</th>
|
||||
<th>Image</th>
|
||||
<th>Created at</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parts.map((part) => (
|
||||
<tr key={part.id}>
|
||||
<td>{truncate(part.id)}</td>
|
||||
<td>{truncate(part.name)}</td>
|
||||
<td>{truncate(part.description)}</td>
|
||||
<td>{truncate(part.availableStock)}</td>
|
||||
<td>
|
||||
<a href={part.imageUrl} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
alt={`${part.name} thumbnail`}
|
||||
src={thumbnail(part.imageUrl)}
|
||||
style={{ maxWidth: '50px' }}
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td>{timeTag(part.createdAt)}</td>
|
||||
<td>
|
||||
<nav className="rw-table-actions">
|
||||
<Link
|
||||
to={routes.part({ id: part.id })}
|
||||
title={'Show part ' + part.id + ' detail'}
|
||||
className="rw-button rw-button-small"
|
||||
>
|
||||
Show
|
||||
</Link>
|
||||
<Link
|
||||
to={routes.editPart({ id: part.id })}
|
||||
title={'Edit part ' + part.id}
|
||||
className="rw-button rw-button-small rw-button-blue"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title={'Delete part ' + part.id}
|
||||
className="rw-button rw-button-small rw-button-red"
|
||||
onClick={() => onDeleteClick(part.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</nav>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PartsList
|
40
web/src/components/Part/PartsCell/PartsCell.tsx
Normal file
40
web/src/components/Part/PartsCell/PartsCell.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import type { FindParts } from 'types/graphql'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
|
||||
|
||||
import Parts from 'src/components/Part/Parts'
|
||||
|
||||
export const QUERY = gql`
|
||||
query FindParts {
|
||||
parts {
|
||||
id
|
||||
name
|
||||
description
|
||||
availableStock
|
||||
imageUrl
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => {
|
||||
return (
|
||||
<div className="rw-text-center">
|
||||
{'No parts yet. '}
|
||||
<Link to={routes.newPart()} className="rw-link">
|
||||
{'Create one?'}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Failure = ({ error }: CellFailureProps) => (
|
||||
<div className="rw-cell-error">{error?.message}</div>
|
||||
)
|
||||
|
||||
export const Success = ({ parts }: CellSuccessProps<FindParts>) => {
|
||||
return <Parts parts={parts} />
|
||||
}
|
37
web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx
Normal file
37
web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import { Toaster } from '@redwoodjs/web/toast'
|
||||
|
||||
type LayoutProps = {
|
||||
title: string
|
||||
titleTo: string
|
||||
buttonLabel: string
|
||||
buttonTo: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const ScaffoldLayout = ({
|
||||
title,
|
||||
titleTo,
|
||||
buttonLabel,
|
||||
buttonTo,
|
||||
children,
|
||||
}: LayoutProps) => {
|
||||
return (
|
||||
<div className="rw-scaffold">
|
||||
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
|
||||
<header className="rw-header">
|
||||
<h1 className="rw-heading rw-heading-primary">
|
||||
<Link to={routes[titleTo]()} className="rw-link">
|
||||
{title}
|
||||
</Link>
|
||||
</h1>
|
||||
<Link to={routes[buttonTo]()} className="rw-button rw-button-green">
|
||||
<div className="rw-button-icon">+</div> {buttonLabel}
|
||||
</Link>
|
||||
</header>
|
||||
<main className="rw-main">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScaffoldLayout
|
192
web/src/lib/formatters.test.tsx
Normal file
192
web/src/lib/formatters.test.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import { render, waitFor, screen } from '@redwoodjs/testing/web'
|
||||
|
||||
import {
|
||||
formatEnum,
|
||||
jsonTruncate,
|
||||
truncate,
|
||||
timeTag,
|
||||
jsonDisplay,
|
||||
checkboxInputTag,
|
||||
} from './formatters'
|
||||
|
||||
describe('formatEnum', () => {
|
||||
it('handles nullish values', () => {
|
||||
expect(formatEnum(null)).toEqual('')
|
||||
expect(formatEnum('')).toEqual('')
|
||||
expect(formatEnum(undefined)).toEqual('')
|
||||
})
|
||||
|
||||
it('formats a list of values', () => {
|
||||
expect(
|
||||
formatEnum(['RED', 'ORANGE', 'YELLOW', 'GREEN', 'BLUE', 'VIOLET'])
|
||||
).toEqual('Red, Orange, Yellow, Green, Blue, Violet')
|
||||
})
|
||||
|
||||
it('formats a single value', () => {
|
||||
expect(formatEnum('DARK_BLUE')).toEqual('Dark blue')
|
||||
})
|
||||
|
||||
it('returns an empty string for values of the wrong type (for JS projects)', () => {
|
||||
// @ts-expect-error - Testing JS scenario
|
||||
expect(formatEnum(5)).toEqual('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncate', () => {
|
||||
it('truncates really long strings', () => {
|
||||
expect(truncate('na '.repeat(1000) + 'batman').length).toBeLessThan(1000)
|
||||
expect(truncate('na '.repeat(1000) + 'batman')).not.toMatch(/batman/)
|
||||
})
|
||||
|
||||
it('does not modify short strings', () => {
|
||||
expect(truncate('Short strinG')).toEqual('Short strinG')
|
||||
})
|
||||
|
||||
it('adds ... to the end of truncated strings', () => {
|
||||
expect(truncate('repeat'.repeat(1000))).toMatch(/\w\.\.\.$/)
|
||||
})
|
||||
|
||||
it('accepts numbers', () => {
|
||||
expect(truncate(123)).toEqual('123')
|
||||
expect(truncate(0)).toEqual('0')
|
||||
expect(truncate(0o000)).toEqual('0')
|
||||
})
|
||||
|
||||
it('handles arguments of invalid type', () => {
|
||||
// @ts-expect-error - Testing JS scenario
|
||||
expect(truncate(false)).toEqual('false')
|
||||
|
||||
expect(truncate(undefined)).toEqual('')
|
||||
expect(truncate(null)).toEqual('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('jsonTruncate', () => {
|
||||
it('truncates large json structures', () => {
|
||||
expect(
|
||||
jsonTruncate({
|
||||
foo: 'foo',
|
||||
bar: 'bar',
|
||||
baz: 'baz',
|
||||
kittens: 'kittens meow',
|
||||
bazinga: 'Sheldon',
|
||||
nested: {
|
||||
foobar: 'I have no imagination',
|
||||
two: 'Second nested item',
|
||||
},
|
||||
five: 5,
|
||||
bool: false,
|
||||
})
|
||||
).toMatch(/.+\n.+\w\.\.\.$/s)
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeTag', () => {
|
||||
it('renders a date', async () => {
|
||||
render(<div>{timeTag(new Date('1970-08-20').toUTCString())}</div>)
|
||||
|
||||
await waitFor(() => screen.getByText(/1970.*00:00:00/))
|
||||
})
|
||||
|
||||
it('can take an empty input string', async () => {
|
||||
expect(timeTag('')).toEqual('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('jsonDisplay', () => {
|
||||
it('produces the correct output', () => {
|
||||
expect(
|
||||
jsonDisplay({
|
||||
title: 'TOML Example (but in JSON)',
|
||||
database: {
|
||||
data: [['delta', 'phi'], [3.14]],
|
||||
enabled: true,
|
||||
ports: [8000, 8001, 8002],
|
||||
temp_targets: {
|
||||
case: 72.0,
|
||||
cpu: 79.5,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
dob: '1979-05-27T07:32:00-08:00',
|
||||
name: 'Tom Preston-Werner',
|
||||
},
|
||||
servers: {
|
||||
alpha: {
|
||||
ip: '10.0.0.1',
|
||||
role: 'frontend',
|
||||
},
|
||||
beta: {
|
||||
ip: '10.0.0.2',
|
||||
role: 'backend',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
<pre>
|
||||
<code>
|
||||
{
|
||||
"title": "TOML Example (but in JSON)",
|
||||
"database": {
|
||||
"data": [
|
||||
[
|
||||
"delta",
|
||||
"phi"
|
||||
],
|
||||
[
|
||||
3.14
|
||||
]
|
||||
],
|
||||
"enabled": true,
|
||||
"ports": [
|
||||
8000,
|
||||
8001,
|
||||
8002
|
||||
],
|
||||
"temp_targets": {
|
||||
"case": 72,
|
||||
"cpu": 79.5
|
||||
}
|
||||
},
|
||||
"owner": {
|
||||
"dob": "1979-05-27T07:32:00-08:00",
|
||||
"name": "Tom Preston-Werner"
|
||||
},
|
||||
"servers": {
|
||||
"alpha": {
|
||||
"ip": "10.0.0.1",
|
||||
"role": "frontend"
|
||||
},
|
||||
"beta": {
|
||||
"ip": "10.0.0.2",
|
||||
"role": "backend"
|
||||
}
|
||||
}
|
||||
}
|
||||
</code>
|
||||
</pre>
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkboxInputTag', () => {
|
||||
it('can be checked', () => {
|
||||
render(checkboxInputTag(true))
|
||||
expect(screen.getByRole('checkbox')).toBeChecked()
|
||||
})
|
||||
|
||||
it('can be unchecked', () => {
|
||||
render(checkboxInputTag(false))
|
||||
expect(screen.getByRole('checkbox')).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('is disabled when checked', () => {
|
||||
render(checkboxInputTag(true))
|
||||
expect(screen.getByRole('checkbox')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('is disabled when unchecked', () => {
|
||||
render(checkboxInputTag(false))
|
||||
expect(screen.getByRole('checkbox')).toBeDisabled()
|
||||
})
|
||||
})
|
58
web/src/lib/formatters.tsx
Normal file
58
web/src/lib/formatters.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
|
||||
import humanize from 'humanize-string'
|
||||
|
||||
const MAX_STRING_LENGTH = 150
|
||||
|
||||
export const formatEnum = (values: string | string[] | null | undefined) => {
|
||||
let output = ''
|
||||
|
||||
if (Array.isArray(values)) {
|
||||
const humanizedValues = values.map((value) => humanize(value))
|
||||
output = humanizedValues.join(', ')
|
||||
} else if (typeof values === 'string') {
|
||||
output = humanize(values)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export const jsonDisplay = (obj: unknown) => {
|
||||
return (
|
||||
<pre>
|
||||
<code>{JSON.stringify(obj, null, 2)}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export const truncate = (value: string | number) => {
|
||||
let output = value?.toString() ?? ''
|
||||
|
||||
if (output.length > MAX_STRING_LENGTH) {
|
||||
output = output.substring(0, MAX_STRING_LENGTH) + '...'
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export const jsonTruncate = (obj: unknown) => {
|
||||
return truncate(JSON.stringify(obj, null, 2))
|
||||
}
|
||||
|
||||
export const timeTag = (dateTime?: string) => {
|
||||
let output: string | JSX.Element = ''
|
||||
|
||||
if (dateTime) {
|
||||
output = (
|
||||
<time dateTime={dateTime} title={dateTime}>
|
||||
{new Date(dateTime).toUTCString()}
|
||||
</time>
|
||||
)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export const checkboxInputTag = (checked: boolean) => {
|
||||
return <input type="checkbox" checked={checked} disabled />
|
||||
}
|
11
web/src/pages/Part/EditPartPage/EditPartPage.tsx
Normal file
11
web/src/pages/Part/EditPartPage/EditPartPage.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import EditPartCell from 'src/components/Part/EditPartCell'
|
||||
|
||||
type PartPageProps = {
|
||||
id: number
|
||||
}
|
||||
|
||||
const EditPartPage = ({ id }: PartPageProps) => {
|
||||
return <EditPartCell id={id} />
|
||||
}
|
||||
|
||||
export default EditPartPage
|
7
web/src/pages/Part/NewPartPage/NewPartPage.tsx
Normal file
7
web/src/pages/Part/NewPartPage/NewPartPage.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import NewPart from 'src/components/Part/NewPart'
|
||||
|
||||
const NewPartPage = () => {
|
||||
return <NewPart />
|
||||
}
|
||||
|
||||
export default NewPartPage
|
11
web/src/pages/Part/PartPage/PartPage.tsx
Normal file
11
web/src/pages/Part/PartPage/PartPage.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import PartCell from 'src/components/Part/PartCell'
|
||||
|
||||
type PartPageProps = {
|
||||
id: number
|
||||
}
|
||||
|
||||
const PartPage = ({ id }: PartPageProps) => {
|
||||
return <PartCell id={id} />
|
||||
}
|
||||
|
||||
export default PartPage
|
7
web/src/pages/Part/PartsPage/PartsPage.tsx
Normal file
7
web/src/pages/Part/PartsPage/PartsPage.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import PartsCell from 'src/components/Part/PartsCell'
|
||||
|
||||
const PartsPage = () => {
|
||||
return <PartsCell />
|
||||
}
|
||||
|
||||
export default PartsPage
|
243
web/src/scaffold.css
Normal file
243
web/src/scaffold.css
Normal file
@ -0,0 +1,243 @@
|
||||
.rw-scaffold {
|
||||
@apply bg-white text-gray-600;
|
||||
}
|
||||
.rw-scaffold h1,
|
||||
.rw-scaffold h2 {
|
||||
@apply m-0;
|
||||
}
|
||||
.rw-scaffold a {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
.rw-scaffold ul {
|
||||
@apply m-0 p-0;
|
||||
}
|
||||
.rw-scaffold input:-ms-input-placeholder {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
.rw-scaffold input::-ms-input-placeholder {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
.rw-scaffold input::placeholder {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
.rw-header {
|
||||
@apply flex justify-between px-8 py-4;
|
||||
}
|
||||
.rw-main {
|
||||
@apply mx-4 pb-4;
|
||||
}
|
||||
.rw-segment {
|
||||
@apply w-full overflow-hidden rounded-lg border border-gray-200;
|
||||
scrollbar-color: theme('colors.zinc.400') transparent;
|
||||
}
|
||||
.rw-segment::-webkit-scrollbar {
|
||||
height: initial;
|
||||
}
|
||||
.rw-segment::-webkit-scrollbar-track {
|
||||
@apply rounded-b-[10px] rounded-t-none border-0 border-t border-solid border-gray-200 bg-transparent p-[2px];
|
||||
}
|
||||
.rw-segment::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full border-[3px] border-solid border-transparent bg-zinc-400 bg-clip-content;
|
||||
}
|
||||
.rw-segment-header {
|
||||
@apply bg-gray-200 px-4 py-3 text-gray-700;
|
||||
}
|
||||
.rw-segment-main {
|
||||
@apply bg-gray-100 p-4;
|
||||
}
|
||||
.rw-link {
|
||||
@apply text-blue-400 underline;
|
||||
}
|
||||
.rw-link:hover {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
.rw-forgot-link {
|
||||
@apply mt-1 text-right text-xs text-gray-400 underline;
|
||||
}
|
||||
.rw-forgot-link:hover {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
.rw-heading {
|
||||
@apply font-semibold;
|
||||
}
|
||||
.rw-heading.rw-heading-primary {
|
||||
@apply text-xl;
|
||||
}
|
||||
.rw-heading.rw-heading-secondary {
|
||||
@apply text-sm;
|
||||
}
|
||||
.rw-heading .rw-link {
|
||||
@apply text-gray-600 no-underline;
|
||||
}
|
||||
.rw-heading .rw-link:hover {
|
||||
@apply text-gray-900 underline;
|
||||
}
|
||||
.rw-cell-error {
|
||||
@apply text-sm font-semibold;
|
||||
}
|
||||
.rw-form-wrapper {
|
||||
@apply -mt-4 text-sm;
|
||||
}
|
||||
.rw-cell-error,
|
||||
.rw-form-error-wrapper {
|
||||
@apply my-4 rounded border border-red-100 bg-red-50 p-4 text-red-600;
|
||||
}
|
||||
.rw-form-error-title {
|
||||
@apply m-0 font-semibold;
|
||||
}
|
||||
.rw-form-error-list {
|
||||
@apply mt-2 list-inside list-disc;
|
||||
}
|
||||
.rw-button {
|
||||
@apply flex cursor-pointer justify-center rounded border-0 bg-gray-200 px-4 py-1 text-xs font-semibold uppercase leading-loose tracking-wide text-gray-500 no-underline transition duration-100;
|
||||
}
|
||||
.rw-button:hover {
|
||||
@apply bg-gray-500 text-white;
|
||||
}
|
||||
.rw-button.rw-button-small {
|
||||
@apply rounded-sm px-2 py-1 text-xs;
|
||||
}
|
||||
.rw-button.rw-button-green {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
.rw-button.rw-button-green:hover {
|
||||
@apply bg-green-700;
|
||||
}
|
||||
.rw-button.rw-button-blue {
|
||||
@apply bg-blue-500 text-white;
|
||||
}
|
||||
.rw-button.rw-button-blue:hover {
|
||||
@apply bg-blue-700;
|
||||
}
|
||||
.rw-button.rw-button-red {
|
||||
@apply bg-red-500 text-white;
|
||||
}
|
||||
.rw-button.rw-button-red:hover {
|
||||
@apply bg-red-700 text-white;
|
||||
}
|
||||
.rw-button-icon {
|
||||
@apply mr-1 text-xl leading-5;
|
||||
}
|
||||
.rw-button-group {
|
||||
@apply mx-2 my-3 flex justify-center;
|
||||
}
|
||||
.rw-button-group .rw-button {
|
||||
@apply mx-1;
|
||||
}
|
||||
.rw-form-wrapper .rw-button-group {
|
||||
@apply mt-8;
|
||||
}
|
||||
.rw-label {
|
||||
@apply mt-6 block text-left font-semibold text-gray-600;
|
||||
}
|
||||
.rw-label.rw-label-error {
|
||||
@apply text-red-600;
|
||||
}
|
||||
.rw-input {
|
||||
@apply mt-2 block w-full rounded border border-gray-200 bg-white p-2 outline-none;
|
||||
}
|
||||
.rw-check-radio-items {
|
||||
@apply flex justify-items-center;
|
||||
}
|
||||
.rw-check-radio-item-none {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
.rw-input[type='checkbox'],
|
||||
.rw-input[type='radio'] {
|
||||
@apply ml-0 mr-1 mt-1 inline w-4;
|
||||
}
|
||||
.rw-input:focus {
|
||||
@apply border-gray-400;
|
||||
}
|
||||
.rw-input-error {
|
||||
@apply border-red-600 text-red-600;
|
||||
}
|
||||
.rw-input-error:focus {
|
||||
@apply border-red-600 outline-none;
|
||||
box-shadow: 0 0 5px #c53030;
|
||||
}
|
||||
.rw-field-error {
|
||||
@apply mt-1 block text-xs font-semibold uppercase text-red-600;
|
||||
}
|
||||
.rw-table-wrapper-responsive {
|
||||
@apply overflow-x-auto;
|
||||
}
|
||||
.rw-table-wrapper-responsive .rw-table {
|
||||
min-width: 48rem;
|
||||
}
|
||||
.rw-table {
|
||||
@apply w-full text-sm;
|
||||
}
|
||||
.rw-table th,
|
||||
.rw-table td {
|
||||
@apply p-3;
|
||||
}
|
||||
.rw-table td {
|
||||
@apply bg-white text-gray-900;
|
||||
}
|
||||
.rw-table tr:nth-child(odd) td,
|
||||
.rw-table tr:nth-child(odd) th {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
.rw-table thead tr {
|
||||
@apply bg-gray-200 text-gray-600;
|
||||
}
|
||||
.rw-table th {
|
||||
@apply text-left font-semibold;
|
||||
}
|
||||
.rw-table thead th {
|
||||
@apply text-left;
|
||||
}
|
||||
.rw-table tbody th {
|
||||
@apply text-right;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.rw-table tbody th {
|
||||
@apply w-1/5;
|
||||
}
|
||||
}
|
||||
.rw-table tbody tr {
|
||||
@apply border-t border-gray-200;
|
||||
}
|
||||
.rw-table input {
|
||||
@apply ml-0;
|
||||
}
|
||||
.rw-table-actions {
|
||||
@apply flex h-4 items-center justify-end pr-1;
|
||||
}
|
||||
.rw-table-actions .rw-button {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
.rw-table-actions .rw-button:hover {
|
||||
@apply bg-gray-500 text-white;
|
||||
}
|
||||
.rw-table-actions .rw-button-blue {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
.rw-table-actions .rw-button-blue:hover {
|
||||
@apply bg-blue-500 text-white;
|
||||
}
|
||||
.rw-table-actions .rw-button-red {
|
||||
@apply text-red-600;
|
||||
}
|
||||
.rw-table-actions .rw-button-red:hover {
|
||||
@apply bg-red-600 text-white;
|
||||
}
|
||||
.rw-text-center {
|
||||
@apply text-center;
|
||||
}
|
||||
.rw-login-container {
|
||||
@apply mx-auto my-16 flex w-96 flex-wrap items-center justify-center;
|
||||
}
|
||||
.rw-login-container .rw-form-wrapper {
|
||||
@apply w-full text-center;
|
||||
}
|
||||
.rw-login-link {
|
||||
@apply mt-4 w-full text-center text-sm text-gray-600;
|
||||
}
|
||||
.rw-webauthn-wrapper {
|
||||
@apply mx-4 mt-6 leading-6;
|
||||
}
|
||||
.rw-webauthn-wrapper h2 {
|
||||
@apply mb-4 text-xl font-bold;
|
||||
}
|
@ -26,7 +26,7 @@
|
||||
"@redwoodjs/testing": ["../node_modules/@redwoodjs/testing/web"]
|
||||
},
|
||||
"typeRoots": ["../node_modules/@types", "./node_modules/@types"],
|
||||
"types": ["jest", "@testing-library/jest-dom"],
|
||||
"types": ["jest", "@testing-library/jest-dom", "node"],
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": [
|
||||
|
210
yarn.lock
210
yarn.lock
@ -2449,6 +2449,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@filestack/loader@npm:^1.0.4":
|
||||
version: 1.0.9
|
||||
resolution: "@filestack/loader@npm:1.0.9"
|
||||
checksum: d2ee1d83502eef02422af94a4280ff21c3e5eb64b07f37ebdb99a34bbe540a8eaec64b1199a097bebe1d10f3b6fee3a32db89fe10a5e45375d8ab327b12b62e4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@graphql-codegen/add@npm:4.0.1":
|
||||
version: 4.0.1
|
||||
resolution: "@graphql-codegen/add@npm:4.0.1"
|
||||
@ -4814,6 +4821,45 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sentry/hub@npm:6.19.7":
|
||||
version: 6.19.7
|
||||
resolution: "@sentry/hub@npm:6.19.7"
|
||||
dependencies:
|
||||
"@sentry/types": 6.19.7
|
||||
"@sentry/utils": 6.19.7
|
||||
tslib: ^1.9.3
|
||||
checksum: 586ac17c01c4ae4d4202adc0d0cfe861ee1087b637ad8692f01c265408b5792f4c14e0dd73506aa266be310665e461d785d083285d63e0ef6c1a1ae43c3d6d50
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sentry/minimal@npm:^6.2.1":
|
||||
version: 6.19.7
|
||||
resolution: "@sentry/minimal@npm:6.19.7"
|
||||
dependencies:
|
||||
"@sentry/hub": 6.19.7
|
||||
"@sentry/types": 6.19.7
|
||||
tslib: ^1.9.3
|
||||
checksum: 86f77d62d8ab5364cc1d14088b557045f24543f2354a959840fbc170c2fc38f9406c2d1be2ae33cad501398c0cc066a7f02b6c8f0155e844e70372c77c56f860
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sentry/types@npm:6.19.7":
|
||||
version: 6.19.7
|
||||
resolution: "@sentry/types@npm:6.19.7"
|
||||
checksum: b428ee58ca5f1587a5bdcf5ae19de0116f5c73eba056872b3a54ff2221d0f5166f3ef28867a8563f00d3da08e55ed3e24baad207b4d1d918596867f99c0ec705
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sentry/utils@npm:6.19.7":
|
||||
version: 6.19.7
|
||||
resolution: "@sentry/utils@npm:6.19.7"
|
||||
dependencies:
|
||||
"@sentry/types": 6.19.7
|
||||
tslib: ^1.9.3
|
||||
checksum: 3c15e6bc75800124924da5b180137007e74d39e605c01bd28d2cfd63ee97fac1ea0c3ec8be712a1ef70802730184b71d0f3b6d50c41da9947fef348f1fd68e12
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sinclair/typebox@npm:^0.27.8":
|
||||
version: 0.27.8
|
||||
resolution: "@sinclair/typebox@npm:0.27.8"
|
||||
@ -5228,6 +5274,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/filestack-react@npm:^4.0.3":
|
||||
version: 4.0.3
|
||||
resolution: "@types/filestack-react@npm:4.0.3"
|
||||
dependencies:
|
||||
"@types/node": "*"
|
||||
"@types/react": "*"
|
||||
filestack-js: ^3.20.0
|
||||
checksum: c00f56f83dcc495944ef6a10ae5de6b6c0c7ff442aa4aa4adc2dc14a815635c9462d91044d30ee9daef63468772c435bed2743b966826c962a7174112a34c0d4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/graceful-fs@npm:^4.1.3":
|
||||
version: 4.1.7
|
||||
resolution: "@types/graceful-fs@npm:4.1.7"
|
||||
@ -5411,6 +5468,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:^20.8.9":
|
||||
version: 20.8.9
|
||||
resolution: "@types/node@npm:20.8.9"
|
||||
dependencies:
|
||||
undici-types: ~5.26.4
|
||||
checksum: 6fb5604ac087c8be9aeb9ee1413fae2e691c603c9a691bd722e113597b883f21e8380a44d114ab894b435a491bfc939c8478cd57bcf890c585b961343b124964
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/normalize-package-data@npm:^2.4.0":
|
||||
version: 2.4.2
|
||||
resolution: "@types/normalize-package-data@npm:2.4.2"
|
||||
@ -6275,7 +6341,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"abab@npm:^2.0.6":
|
||||
"abab@npm:^2.0.3, abab@npm:^2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "abab@npm:2.0.6"
|
||||
checksum: 0b245c3c3ea2598fe0025abf7cc7bb507b06949d51e8edae5d12c1b847a0a0c09639abcb94788332b4e2044ac4491c1e8f571b51c7826fd4b0bda1685ad4a278
|
||||
@ -6633,6 +6699,7 @@ __metadata:
|
||||
dependencies:
|
||||
"@redwoodjs/api": 6.3.2
|
||||
"@redwoodjs/graphql-server": 6.3.2
|
||||
filestack-js: ^3.27.0
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
@ -10711,6 +10778,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eventemitter3@npm:^3.1.0":
|
||||
version: 3.1.2
|
||||
resolution: "eventemitter3@npm:3.1.2"
|
||||
checksum: c67262eccbf85848b7cc6d4abb6c6e34155e15686db2a01c57669fd0d44441a574a19d44d25948b442929e065774cbe5003d8e77eed47674fbf876ac77887793
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eventemitter3@npm:^4.0.0":
|
||||
version: 4.0.7
|
||||
resolution: "eventemitter3@npm:4.0.7"
|
||||
@ -11002,6 +11076,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fast-xml-parser@npm:^4.2.4":
|
||||
version: 4.3.2
|
||||
resolution: "fast-xml-parser@npm:4.3.2"
|
||||
dependencies:
|
||||
strnum: ^1.0.5
|
||||
bin:
|
||||
fxparser: src/cli/cli.js
|
||||
checksum: 7c1611349384656ec4faa9802fbc8cf8c01206a1b79193d5cd54586307801562509007f6cf16e5da7d43da4fa4639770f38959a285b9466aa98dab0a9b8ca171
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fastest-levenshtein@npm:^1.0.12":
|
||||
version: 1.0.16
|
||||
resolution: "fastest-levenshtein@npm:1.0.16"
|
||||
@ -11132,6 +11217,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"file-type@npm:^10.11.0":
|
||||
version: 10.11.0
|
||||
resolution: "file-type@npm:10.11.0"
|
||||
checksum: 2d6280d84f2499878ebdf8236a6e83b3c747f08b91d84cf99785afe3c9ac52775e52dcec15a4141cc24eb3006f274eb46dc7d13395920a1763d936c6d6e8afde
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"file-uri-to-path@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "file-uri-to-path@npm:1.0.0"
|
||||
@ -11139,6 +11231,41 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"filestack-js@npm:^3.20.0, filestack-js@npm:^3.27.0":
|
||||
version: 3.27.0
|
||||
resolution: "filestack-js@npm:3.27.0"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.8.4
|
||||
"@filestack/loader": ^1.0.4
|
||||
"@sentry/minimal": ^6.2.1
|
||||
abab: ^2.0.3
|
||||
debug: ^4.1.1
|
||||
eventemitter3: ^4.0.0
|
||||
fast-xml-parser: ^4.2.4
|
||||
file-type: ^10.11.0
|
||||
follow-redirects: ^1.10.0
|
||||
isutf8: ^2.1.0
|
||||
jsonschema: ^1.2.5
|
||||
lodash.clonedeep: ^4.5.0
|
||||
p-queue: ^4.0.0
|
||||
spark-md5: ^3.0.0
|
||||
ts-node: ^8.10.2
|
||||
checksum: a7dc07e94e00d9da13cf297cef4ae504e1a798beac42ec63662166dd9479eb2fda266c005ca18d798b44683f3cf7cfb75a49bb42a4b234fe761348f211c4f341
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"filestack-react@npm:^4.0.1":
|
||||
version: 4.0.1
|
||||
resolution: "filestack-react@npm:4.0.1"
|
||||
dependencies:
|
||||
filestack-js: ^3.20.0
|
||||
peerDependencies:
|
||||
react: ^16.13.1
|
||||
react-dom: ^16.13.1
|
||||
checksum: 80125b40059eb64313403c6d4bad4782b3f6c253628efeb8967972b2aca7462c834fd104e0912d8c26ca1e5807dbab65821c6156cf3fa05f2be94b2341f582f0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fill-keys@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "fill-keys@npm:1.0.2"
|
||||
@ -11315,7 +11442,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"follow-redirects@npm:^1.0.0":
|
||||
"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.10.0":
|
||||
version: 1.15.3
|
||||
resolution: "follow-redirects@npm:1.15.3"
|
||||
peerDependenciesMeta:
|
||||
@ -13285,6 +13412,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isutf8@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "isutf8@npm:2.1.0"
|
||||
checksum: 613b9ad066a026001577db32df496849e45ac06c6cac3cc4ccdfbd326a3597322c421f18669bcb26440973f209363d17068971d6c880adfe9391158587dfd56a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jackspeak@npm:^2.3.5":
|
||||
version: 2.3.6
|
||||
resolution: "jackspeak@npm:2.3.6"
|
||||
@ -14022,6 +14156,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsonschema@npm:^1.2.5":
|
||||
version: 1.4.1
|
||||
resolution: "jsonschema@npm:1.4.1"
|
||||
checksum: c3422d3fc7d33ff7234a806ffa909bb6fb5d1cd664bea229c64a1785dc04cbccd5fc76cf547c6ab6dd7881dbcaf3540a6a9f925a5956c61a9cd3e23a3c1796ef
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsonwebtoken@npm:9.0.0":
|
||||
version: 9.0.0
|
||||
resolution: "jsonwebtoken@npm:9.0.0"
|
||||
@ -14377,6 +14518,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.clonedeep@npm:^4.5.0":
|
||||
version: 4.5.0
|
||||
resolution: "lodash.clonedeep@npm:4.5.0"
|
||||
checksum: 2caf0e4808f319d761d2939ee0642fa6867a4bbf2cfce43276698828380756b99d4c4fa226d881655e6ac298dd453fe12a5ec8ba49861777759494c534936985
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.debounce@npm:^4.0.8":
|
||||
version: 4.0.8
|
||||
resolution: "lodash.debounce@npm:4.0.8"
|
||||
@ -15935,6 +16083,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"p-queue@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "p-queue@npm:4.0.0"
|
||||
dependencies:
|
||||
eventemitter3: ^3.1.0
|
||||
checksum: 9ae4e2fd428447d4ae27028e8bd8bc3384bee93a6a115b96463de14b8580b326fc4c546e003052bd1c5bb079991b888f898757e8c4c0a65fcb36de0f110c00af
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"p-retry@npm:4.6.2, p-retry@npm:^4.5.0":
|
||||
version: 4.6.2
|
||||
resolution: "p-retry@npm:4.6.2"
|
||||
@ -18714,7 +18871,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"source-map-support@npm:^0.5.16, source-map-support@npm:~0.5.12, source-map-support@npm:~0.5.20":
|
||||
"source-map-support@npm:^0.5.16, source-map-support@npm:^0.5.17, source-map-support@npm:~0.5.12, source-map-support@npm:~0.5.20":
|
||||
version: 0.5.21
|
||||
resolution: "source-map-support@npm:0.5.21"
|
||||
dependencies:
|
||||
@ -18752,6 +18909,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"spark-md5@npm:^3.0.0":
|
||||
version: 3.0.2
|
||||
resolution: "spark-md5@npm:3.0.2"
|
||||
checksum: 3fd11735eac5e7d60d6006d99ac0a055f148a89e9baf5f0b51ac103022dec30556b44190b37f6737ca50f81e8e50dc13e724f9edf6290c412ff5ab2101ce7780
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"spawn-command@npm:0.0.2":
|
||||
version: 0.0.2
|
||||
resolution: "spawn-command@npm:0.0.2"
|
||||
@ -19171,6 +19335,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"strnum@npm:^1.0.5":
|
||||
version: 1.0.5
|
||||
resolution: "strnum@npm:1.0.5"
|
||||
checksum: 64fb8cc2effbd585a6821faa73ad97d4b553c8927e49086a162ffd2cc818787643390b89d567460a8e74300148d11ac052e21c921ef2049f2987f4b1b89a7ff1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"style-loader@npm:3.3.3":
|
||||
version: 3.3.3
|
||||
resolution: "style-loader@npm:3.3.3"
|
||||
@ -19812,6 +19983,26 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ts-node@npm:^8.10.2":
|
||||
version: 8.10.2
|
||||
resolution: "ts-node@npm:8.10.2"
|
||||
dependencies:
|
||||
arg: ^4.1.0
|
||||
diff: ^4.0.1
|
||||
make-error: ^1.1.1
|
||||
source-map-support: ^0.5.17
|
||||
yn: 3.1.1
|
||||
peerDependencies:
|
||||
typescript: ">=2.7"
|
||||
bin:
|
||||
ts-node: dist/bin.js
|
||||
ts-node-script: dist/bin-script.js
|
||||
ts-node-transpile-only: dist/bin-transpile.js
|
||||
ts-script: dist/bin-script-deprecated.js
|
||||
checksum: 628343f62fff2543b4559a93eb27005084aea7609945e77f311031c5e96c4099736646856e1792605b90e8007d2c060fe80783be21c94788d91d6f259aab92e2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ts-pattern@npm:4.3.0":
|
||||
version: 4.3.0
|
||||
resolution: "ts-pattern@npm:4.3.0"
|
||||
@ -19838,7 +20029,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tslib@npm:^1.8.1, tslib@npm:^1.9.2":
|
||||
"tslib@npm:^1.8.1, tslib@npm:^1.9.2, tslib@npm:^1.9.3":
|
||||
version: 1.14.1
|
||||
resolution: "tslib@npm:1.14.1"
|
||||
checksum: 69ae09c49eea644bc5ebe1bca4fa4cc2c82b7b3e02f43b84bd891504edf66dbc6b2ec0eef31a957042de2269139e4acff911e6d186a258fb14069cd7f6febce2
|
||||
@ -20080,6 +20271,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici-types@npm:~5.26.4":
|
||||
version: 5.26.5
|
||||
resolution: "undici-types@npm:5.26.5"
|
||||
checksum: bb673d7876c2d411b6eb6c560e0c571eef4a01c1c19925175d16e3a30c4c428181fb8d7ae802a261f283e4166a0ac435e2f505743aa9e45d893f9a3df017b501
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici@npm:^5.19.1":
|
||||
version: 5.25.4
|
||||
resolution: "undici@npm:5.25.4"
|
||||
@ -20658,10 +20856,14 @@ __metadata:
|
||||
"@redwoodjs/router": 6.3.2
|
||||
"@redwoodjs/vite": 6.3.2
|
||||
"@redwoodjs/web": 6.3.2
|
||||
"@types/filestack-react": ^4.0.3
|
||||
"@types/node": ^20.8.9
|
||||
"@types/react": 18.2.14
|
||||
"@types/react-dom": 18.2.6
|
||||
autoprefixer: ^10.4.16
|
||||
daisyui: ^3.9.3
|
||||
filestack-react: ^4.0.1
|
||||
humanize-string: 2.1.0
|
||||
postcss: ^8.4.31
|
||||
postcss-loader: ^7.3.3
|
||||
prop-types: 15.8.1
|
||||
|
Reference in New Issue
Block a user