diff --git a/.env.defaults b/.env.defaults index fb88fb3..358c897 100644 --- a/.env.defaults +++ b/.env.defaults @@ -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= diff --git a/.env.example b/.env.example index 2a2de6c..d93c75b 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/api/db/migrations/20231027135109_create_part_schema/migration.sql b/api/db/migrations/20231027135109_create_part_schema/migration.sql new file mode 100644 index 0000000..d96ec31 --- /dev/null +++ b/api/db/migrations/20231027135109_create_part_schema/migration.sql @@ -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 +); diff --git a/api/db/migrations/migration_lock.toml b/api/db/migrations/migration_lock.toml new file mode 100644 index 0000000..e5e5c47 --- /dev/null +++ b/api/db/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/api/db/schema.prisma b/api/db/schema.prisma index 3dea71a..d249aae 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -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 { - id Int @id @default(autoincrement()) - email String @unique - name String? +model Part { + id Int @id @default(autoincrement()) + name String + description String? @default("No description provided") + availableStock Int @default(0) + imageUrl String @default("/no_image.png") + createdAt DateTime @default(now()) } diff --git a/api/package.json b/api/package.json index 1ef60a7..6bc4a45 100644 --- a/api/package.json +++ b/api/package.json @@ -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" } } diff --git a/api/src/graphql/parts.sdl.ts b/api/src/graphql/parts.sdl.ts new file mode 100644 index 0000000..622f814 --- /dev/null +++ b/api/src/graphql/parts.sdl.ts @@ -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 + } +` diff --git a/api/src/services/parts/parts.scenarios.ts b/api/src/services/parts/parts.scenarios.ts new file mode 100644 index 0000000..81e0d58 --- /dev/null +++ b/api/src/services/parts/parts.scenarios.ts @@ -0,0 +1,11 @@ +import type { Prisma, Part } from '@prisma/client' +import type { ScenarioData } from '@redwoodjs/testing/api' + +export const standard = defineScenario({ + part: { + one: { data: { name: 'String' } }, + two: { data: { name: 'String' } }, + }, +}) + +export type StandardScenario = ScenarioData diff --git a/api/src/services/parts/parts.test.ts b/api/src/services/parts/parts.test.ts new file mode 100644 index 0000000..12ee523 --- /dev/null +++ b/api/src/services/parts/parts.test.ts @@ -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) + }) +}) diff --git a/api/src/services/parts/parts.ts b/api/src/services/parts/parts.ts new file mode 100644 index 0000000..7eae11a --- /dev/null +++ b/api/src/services/parts/parts.ts @@ -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 }, + }) +} diff --git a/web/package.json b/web/package.json index 122c394..bfe3a5d 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/public/no_image.png b/web/public/no_image.png new file mode 100644 index 0000000..fe7e6f3 Binary files /dev/null and b/web/public/no_image.png differ diff --git a/web/src/App.tsx b/web/src/App.tsx index 97fb5e0..5e7beac 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = () => ( diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index e630b02..f7ce2cd 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -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 ( + + + + + + diff --git a/web/src/components/Part/EditPartCell/EditPartCell.tsx b/web/src/components/Part/EditPartCell/EditPartCell.tsx new file mode 100644 index 0000000..ee118ef --- /dev/null +++ b/web/src/components/Part/EditPartCell/EditPartCell.tsx @@ -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 = () =>
Loading...
+ +export const Failure = ({ error }: CellFailureProps) => ( +
{error?.message}
+) + +export const Success = ({ part }: CellSuccessProps) => { + 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 ( +
+
+

+ Edit Part {part?.id} +

+
+
+ +
+
+ ) +} diff --git a/web/src/components/Part/NewPart/NewPart.tsx b/web/src/components/Part/NewPart/NewPart.tsx new file mode 100644 index 0000000..e548441 --- /dev/null +++ b/web/src/components/Part/NewPart/NewPart.tsx @@ -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 ( +
+
+

New Part

+
+
+ +
+
+ ) +} + +export default NewPart diff --git a/web/src/components/Part/Part/Part.tsx b/web/src/components/Part/Part/Part.tsx new file mode 100644 index 0000000..5a72a45 --- /dev/null +++ b/web/src/components/Part/Part/Part.tsx @@ -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 +} + +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 ( + <> +
+
+

+ Part {part.id} Detail +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Id{part.id}
Name{part.name}
Description{part.description}
Available stock{part.availableStock}
Image{part.imageUrl}
Created at{timeTag(part.createdAt)}
+
+ + + ) +} + +export default Part diff --git a/web/src/components/Part/PartCell/PartCell.tsx b/web/src/components/Part/PartCell/PartCell.tsx new file mode 100644 index 0000000..2e71e35 --- /dev/null +++ b/web/src/components/Part/PartCell/PartCell.tsx @@ -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 = () =>
Loading...
+ +export const Empty = () =>
Part not found
+ +export const Failure = ({ error }: CellFailureProps) => ( +
{error?.message}
+) + +export const Success = ({ part }: CellSuccessProps) => { + return +} diff --git a/web/src/components/Part/PartForm/PartForm.tsx b/web/src/components/Part/PartForm/PartForm.tsx new file mode 100644 index 0000000..ad54b2b --- /dev/null +++ b/web/src/components/Part/PartForm/PartForm.tsx @@ -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 + +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 ( +
+ onSubmit={onSubmit} error={props.error}> + + + + + + + + + + + + + + + + + + + + + + + {!imageUrl && ( +
+ +
+ )} + + {imageUrl && ( +
+ + +
+ )} + + + +
+ + Save + +
+ +
+ ) +} + +export default PartForm diff --git a/web/src/components/Part/Parts/Parts.tsx b/web/src/components/Part/Parts/Parts.tsx new file mode 100644 index 0000000..a2d540d --- /dev/null +++ b/web/src/components/Part/Parts/Parts.tsx @@ -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 ( +
+ + + + + + + + + + + + + + {parts.map((part) => ( + + + + + + + + + + ))} + +
IdNameDescriptionAvailable stockImageCreated at 
{truncate(part.id)}{truncate(part.name)}{truncate(part.description)}{truncate(part.availableStock)} + + {`${part.name} + + {timeTag(part.createdAt)} + +
+
+ ) +} + +export default PartsList diff --git a/web/src/components/Part/PartsCell/PartsCell.tsx b/web/src/components/Part/PartsCell/PartsCell.tsx new file mode 100644 index 0000000..6ebdd28 --- /dev/null +++ b/web/src/components/Part/PartsCell/PartsCell.tsx @@ -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 = () =>
Loading...
+ +export const Empty = () => { + return ( +
+ {'No parts yet. '} + + {'Create one?'} + +
+ ) +} + +export const Failure = ({ error }: CellFailureProps) => ( +
{error?.message}
+) + +export const Success = ({ parts }: CellSuccessProps) => { + return +} diff --git a/web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx b/web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx new file mode 100644 index 0000000..2912b56 --- /dev/null +++ b/web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx @@ -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 ( +
+ +
+

+ + {title} + +

+ +
+
{buttonLabel} + +
+
{children}
+
+ ) +} + +export default ScaffoldLayout diff --git a/web/src/lib/formatters.test.tsx b/web/src/lib/formatters.test.tsx new file mode 100644 index 0000000..5659338 --- /dev/null +++ b/web/src/lib/formatters.test.tsx @@ -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(
{timeTag(new Date('1970-08-20').toUTCString())}
) + + 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(` +
+        
+          {
+        "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"
+          }
+        }
+      }
+        
+      
+ `) + }) +}) + +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() + }) +}) diff --git a/web/src/lib/formatters.tsx b/web/src/lib/formatters.tsx new file mode 100644 index 0000000..8ab9e80 --- /dev/null +++ b/web/src/lib/formatters.tsx @@ -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 ( +
+      {JSON.stringify(obj, null, 2)}
+    
+ ) +} + +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 = ( + + ) + } + + return output +} + +export const checkboxInputTag = (checked: boolean) => { + return +} diff --git a/web/src/pages/Part/EditPartPage/EditPartPage.tsx b/web/src/pages/Part/EditPartPage/EditPartPage.tsx new file mode 100644 index 0000000..ab486ed --- /dev/null +++ b/web/src/pages/Part/EditPartPage/EditPartPage.tsx @@ -0,0 +1,11 @@ +import EditPartCell from 'src/components/Part/EditPartCell' + +type PartPageProps = { + id: number +} + +const EditPartPage = ({ id }: PartPageProps) => { + return +} + +export default EditPartPage diff --git a/web/src/pages/Part/NewPartPage/NewPartPage.tsx b/web/src/pages/Part/NewPartPage/NewPartPage.tsx new file mode 100644 index 0000000..dd89082 --- /dev/null +++ b/web/src/pages/Part/NewPartPage/NewPartPage.tsx @@ -0,0 +1,7 @@ +import NewPart from 'src/components/Part/NewPart' + +const NewPartPage = () => { + return +} + +export default NewPartPage diff --git a/web/src/pages/Part/PartPage/PartPage.tsx b/web/src/pages/Part/PartPage/PartPage.tsx new file mode 100644 index 0000000..2427752 --- /dev/null +++ b/web/src/pages/Part/PartPage/PartPage.tsx @@ -0,0 +1,11 @@ +import PartCell from 'src/components/Part/PartCell' + +type PartPageProps = { + id: number +} + +const PartPage = ({ id }: PartPageProps) => { + return +} + +export default PartPage diff --git a/web/src/pages/Part/PartsPage/PartsPage.tsx b/web/src/pages/Part/PartsPage/PartsPage.tsx new file mode 100644 index 0000000..bfe4eba --- /dev/null +++ b/web/src/pages/Part/PartsPage/PartsPage.tsx @@ -0,0 +1,7 @@ +import PartsCell from 'src/components/Part/PartsCell' + +const PartsPage = () => { + return +} + +export default PartsPage diff --git a/web/src/scaffold.css b/web/src/scaffold.css new file mode 100644 index 0000000..ffa9142 --- /dev/null +++ b/web/src/scaffold.css @@ -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; +} diff --git a/web/tsconfig.json b/web/tsconfig.json index 8b5649a..ea3fad2 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -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": [ diff --git a/yarn.lock b/yarn.lock index be7bfdc..863ed8c 100644 --- a/yarn.lock +++ b/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