Part browsing
This commit is contained in:
@ -8,7 +8,29 @@ export const schema = gql`
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
||||
type PartPage {
|
||||
parts: [Part!]!
|
||||
count: Int!
|
||||
page: Int!
|
||||
sort: SortMethod!
|
||||
order: SortOrder!
|
||||
}
|
||||
|
||||
enum SortMethod {
|
||||
id
|
||||
name
|
||||
description
|
||||
stock
|
||||
createdAt
|
||||
}
|
||||
|
||||
enum SortOrder {
|
||||
ascending
|
||||
descending
|
||||
}
|
||||
|
||||
type Query {
|
||||
partPage(page: Int, sort: SortMethod, order: SortOrder): PartPage @skipAuth
|
||||
parts: [Part!]! @skipAuth
|
||||
part(id: Int!): Part @skipAuth
|
||||
}
|
||||
|
@ -1,8 +1,18 @@
|
||||
import * as Filestack from 'filestack-js'
|
||||
import type { QueryResolvers, MutationResolvers } from 'types/graphql'
|
||||
import type {
|
||||
QueryResolvers,
|
||||
MutationResolvers,
|
||||
SortMethod,
|
||||
SortOrder,
|
||||
} from 'types/graphql'
|
||||
|
||||
import { db } from 'src/lib/db'
|
||||
|
||||
const PARTS_PER_PAGE = 8
|
||||
|
||||
const removeEnding = (input: string): string =>
|
||||
input.endsWith('ending') ? input.slice(0, -6) : input
|
||||
|
||||
export const parts: QueryResolvers['parts'] = () => {
|
||||
return db.part.findMany()
|
||||
}
|
||||
@ -13,6 +23,59 @@ export const part: QueryResolvers['part'] = ({ id }) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const partPage = ({
|
||||
page = 1,
|
||||
sort = 'id',
|
||||
order = 'ascending',
|
||||
}: {
|
||||
page: number
|
||||
sort: SortMethod
|
||||
order: SortOrder
|
||||
}) => {
|
||||
const offset = (page - 1) * PARTS_PER_PAGE
|
||||
let orderByCase
|
||||
|
||||
switch (sort) {
|
||||
case 'id':
|
||||
orderByCase = { id: removeEnding(order) }
|
||||
break
|
||||
|
||||
case 'name':
|
||||
orderByCase = { name: removeEnding(order) }
|
||||
break
|
||||
|
||||
case 'createdAt':
|
||||
orderByCase = { createdAt: removeEnding(order) }
|
||||
break
|
||||
|
||||
case 'description':
|
||||
orderByCase = { description: removeEnding(order) }
|
||||
break
|
||||
|
||||
case 'stock':
|
||||
orderByCase = {
|
||||
availableStock: removeEnding(order),
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
orderByCase = { id: removeEnding(order) }
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
parts: db.part.findMany({
|
||||
take: PARTS_PER_PAGE,
|
||||
skip: offset,
|
||||
orderBy: orderByCase,
|
||||
}),
|
||||
count: db.part.count(),
|
||||
page,
|
||||
sort,
|
||||
order,
|
||||
}
|
||||
}
|
||||
|
||||
export const createPart: MutationResolvers['createPart'] = ({ input }) => {
|
||||
return db.part.create({
|
||||
data: input,
|
||||
|
@ -1,12 +1,3 @@
|
||||
// In this file, all Page components from 'src/pages` are auto-imported. Nested
|
||||
// directories are supported, and should be uppercase. Each subdirectory will be
|
||||
// prepended onto the component name.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// 'src/pages/HomePage/HomePage.js' -> HomePage
|
||||
// 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage
|
||||
|
||||
import { Router, Route, Set, Private } from '@redwoodjs/router'
|
||||
|
||||
import NavbarLayout from 'src/layouts/NavbarLayout'
|
||||
@ -21,6 +12,7 @@ const Routes = () => {
|
||||
<Route path="/signup" page={SignupPage} name="signup" />
|
||||
<Route path="/forgot-password" page={ForgotPasswordPage} name="forgotPassword" />
|
||||
<Route path="/reset-password" page={ResetPasswordPage} name="resetPassword" />
|
||||
|
||||
<Private unauthenticated="home" roles="admin">
|
||||
<Set wrap={ScaffoldLayout} title="Parts" titleTo="parts" buttonLabel="New Part" buttonTo="newPart">
|
||||
<Route path="/admin/parts/new" page={PartNewPartPage} name="newPart" />
|
||||
@ -29,9 +21,12 @@ const Routes = () => {
|
||||
<Route path="/admin/parts" page={PartPartsPage} name="parts" />
|
||||
</Set>
|
||||
</Private>
|
||||
|
||||
<Set wrap={NavbarLayout}>
|
||||
<Route path="/" page={HomePage} name="home" />
|
||||
<Route path="/part/{id:Int}" page={PartPage} name="partDetails" />
|
||||
</Set>
|
||||
|
||||
<Route notfound page={NotFoundPage} />
|
||||
</Router>
|
||||
)
|
||||
|
@ -11,7 +11,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const NavbarAccountIcon = ({ mobile, className }: Props) => {
|
||||
const { isAuthenticated, currentUser, logOut } = useAuth()
|
||||
const { isAuthenticated, currentUser, logOut, hasRole } = useAuth()
|
||||
|
||||
return isAuthenticated ? (
|
||||
<div className={className}>
|
||||
@ -21,11 +21,16 @@ const NavbarAccountIcon = ({ mobile, className }: Props) => {
|
||||
}`}
|
||||
>
|
||||
<summary className="btn btn-ghost swap swap-rotate w-12 hover:shadow-lg">
|
||||
<Icon path={mdiAccount} className="h-8 w-8 text-base-content" />
|
||||
<Icon
|
||||
path={mdiAccount}
|
||||
className={`h-8 w-8 ${
|
||||
hasRole('admin') ? 'text-error' : 'text-base-content'
|
||||
}`}
|
||||
/>
|
||||
</summary>
|
||||
<div className="dropdown-content flex w-auto flex-row items-center space-x-3 rounded-xl bg-base-100 p-3 shadow-lg">
|
||||
<p className="whitespace-nowrap font-inter text-lg">
|
||||
Hello, {currentUser.firstName}!
|
||||
{currentUser ? `Hello, ${currentUser.firstName}!` : ``}
|
||||
</p>
|
||||
<button className="btn btn-ghost" type="button" onClick={logOut}>
|
||||
<Icon path={mdiLogout} className="h-7 w-7 text-base-content" />
|
||||
|
@ -4,7 +4,7 @@ import { Link, routes, navigate } from '@redwoodjs/router'
|
||||
import { useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import { timeTag } from 'src/lib/formatters'
|
||||
import { timeTag } from 'src/pages/lib/formatters'
|
||||
|
||||
const DELETE_PART_MUTATION = gql`
|
||||
mutation DeletePartMutation($id: Int!) {
|
||||
|
@ -28,7 +28,10 @@ const PartForm = (props: PartFormProps) => {
|
||||
const [imageUrl, setImageUrl] = useState(props?.part?.imageUrl)
|
||||
|
||||
const onSubmit = (data: FormPart) => {
|
||||
const dataWithImageUrl = Object.assign(data, { imageUrl })
|
||||
console.log(imageUrl)
|
||||
const dataWithImageUrl = Object.assign(data, {
|
||||
imageUrl: imageUrl ?? '/no_image.png',
|
||||
})
|
||||
props.onSave(dataWithImageUrl, props?.part?.id)
|
||||
}
|
||||
|
||||
@ -81,6 +84,7 @@ const PartForm = (props: PartFormProps) => {
|
||||
errorClassName="rw-input rw-input-error min-w-full"
|
||||
validation={{ required: true, min: 0 }}
|
||||
min={0}
|
||||
max={2147483647}
|
||||
/>
|
||||
|
||||
<FieldError name="availableStock" className="rw-field-error pb-3" />
|
||||
@ -112,7 +116,7 @@ const PartForm = (props: PartFormProps) => {
|
||||
/>
|
||||
<button
|
||||
onClick={() => setImageUrl(null)}
|
||||
className="rw-button rw-button-blue"
|
||||
className="rw-button btn-primary"
|
||||
>
|
||||
Replace Image
|
||||
</button>
|
||||
|
@ -0,0 +1,4 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = (/* vars, { ctx, req } */) => ({
|
||||
partDetails: [{ id: 42 }, { id: 43 }, { id: 44 }],
|
||||
})
|
@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Loading, Empty, Failure, Success } from './PartDetailsCell'
|
||||
import { standard } from './PartDetailsCell.mock'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Cells/PartDetailsCell',
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
export const loading: StoryObj<typeof Loading> = {
|
||||
render: () => {
|
||||
return Loading ? <Loading /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const empty: StoryObj<typeof Empty> = {
|
||||
render: () => {
|
||||
return Empty ? <Empty /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const failure: StoryObj<typeof Failure> = {
|
||||
render: (args) => {
|
||||
return Failure ? <Failure error={new Error('Oh no')} {...args} /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const success: StoryObj<typeof Success> = {
|
||||
render: (args) => {
|
||||
return Success ? <Success {...standard()} {...args} /> : <></>
|
||||
},
|
||||
}
|
41
web/src/components/PartDetailsCell/PartDetailsCell.test.tsx
Normal file
41
web/src/components/PartDetailsCell/PartDetailsCell.test.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
import { Loading, Empty, Failure, Success } from './PartDetailsCell'
|
||||
import { standard } from './PartDetailsCell.mock'
|
||||
|
||||
// Generated boilerplate tests do not account for all circumstances
|
||||
// and can fail without adjustments, e.g. Float and DateTime types.
|
||||
// Please refer to the RedwoodJS Testing Docs:
|
||||
// https://redwoodjs.com/docs/testing#testing-cells
|
||||
// https://redwoodjs.com/docs/testing#jest-expect-type-considerations
|
||||
|
||||
describe('PartDetailsCell', () => {
|
||||
it('renders Loading successfully', () => {
|
||||
expect(() => {
|
||||
render(<Loading />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders Empty successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Empty />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders Failure successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Failure error={new Error('Oh no')} />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
// When you're ready to test the actual output of your component render
|
||||
// you could test that, for example, certain text is present:
|
||||
//
|
||||
// 1. import { screen } from '@redwoodjs/testing/web'
|
||||
// 2. Add test: expect(screen.getByText('Hello, world')).toBeInTheDocument()
|
||||
|
||||
it('renders Success successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Success partDetails={standard().partDetails} />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
99
web/src/components/PartDetailsCell/PartDetailsCell.tsx
Normal file
99
web/src/components/PartDetailsCell/PartDetailsCell.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { mdiAlert, mdiPlus, mdiMinus } from '@mdi/js'
|
||||
import { Icon } from '@mdi/react'
|
||||
import type { FindPartById } from 'types/graphql'
|
||||
|
||||
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
|
||||
|
||||
export const QUERY = gql`
|
||||
query FindPartById($id: Int!) {
|
||||
part: part(id: $id) {
|
||||
name
|
||||
description
|
||||
availableStock
|
||||
imageUrl
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => (
|
||||
<div className="flex w-auto justify-center">
|
||||
<p className="loading loading-bars loading-lg" />
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Empty = () => (
|
||||
<div className="flex justify-center">
|
||||
<div className="alert w-auto">
|
||||
<p className="text-center font-inter">It's empty in here...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Failure = ({ error }: CellFailureProps) => (
|
||||
<div className="flex w-auto justify-center">
|
||||
<div className="alert alert-error w-auto">
|
||||
<Icon path={mdiAlert} className="h-6 w-6" />
|
||||
<p className="font-inter">Error! {error?.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const image = (url: string, size: number) => {
|
||||
if (url.includes('no_image.png')) return url
|
||||
const parts = url.split('/')
|
||||
parts.splice(3, 0, `resize=height:${size}`)
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
export const Success = ({ part }: CellSuccessProps<FindPartById>) => {
|
||||
const [toTake, setToTake] = useState(1)
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||
<div className="col-span-2">
|
||||
<h1 className="font-inter text-4xl font-bold">{part.name}</h1>
|
||||
</div>
|
||||
|
||||
<div className="order-first col-span-2 sm:order-2 sm:col-span-1">
|
||||
<div className="-z-10 flex sm:sticky sm:top-28">
|
||||
<div className="h-0 w-full bg-base-100" />
|
||||
<img
|
||||
alt={part.name}
|
||||
src={image(part.imageUrl, 640)}
|
||||
className="mb-8 w-full rounded-3xl object-cover sm:mb-0 sm:w-64 md:w-80 lg:w-96 xl:w-[32rem] 2xl:w-[40rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-8 font-inter sm:col-span-1">
|
||||
<p className="text-lg">{part.description}</p>
|
||||
<div className="divider" />
|
||||
<p className="text-xl">
|
||||
<strong>Current stock:</strong> {part.availableStock}
|
||||
</p>
|
||||
<div className="flex space-x-5">
|
||||
<div className="join">
|
||||
<button
|
||||
className={`btn join-item ${toTake == 1 ? 'btn-disabled' : ''}`}
|
||||
onClick={() => setToTake(toTake - 1)}
|
||||
>
|
||||
<Icon path={mdiMinus} className="h-6 w-6" />
|
||||
</button>
|
||||
<p className="btn join-item items-center font-inter text-lg">
|
||||
{toTake}
|
||||
</p>
|
||||
<button
|
||||
className={`btn join-item ${
|
||||
toTake == part.availableStock ? 'btn-disabled' : ''
|
||||
}`}
|
||||
onClick={() => setToTake(toTake + 1)}
|
||||
>
|
||||
<Icon path={mdiPlus} className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn btn-primary">Add to basket</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
4
web/src/components/PartsCell/PartsCell.mock.ts
Normal file
4
web/src/components/PartsCell/PartsCell.mock.ts
Normal file
@ -0,0 +1,4 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = (/* vars, { ctx, req } */) => ({
|
||||
parts: [{ id: 42 }, { id: 43 }, { id: 44 }],
|
||||
})
|
34
web/src/components/PartsCell/PartsCell.stories.tsx
Normal file
34
web/src/components/PartsCell/PartsCell.stories.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Loading, Empty, Failure, Success } from './PartsCell'
|
||||
import { standard } from './PartsCell.mock'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Cells/PartsCell',
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
export const loading: StoryObj<typeof Loading> = {
|
||||
render: () => {
|
||||
return Loading ? <Loading /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const empty: StoryObj<typeof Empty> = {
|
||||
render: () => {
|
||||
return Empty ? <Empty /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const failure: StoryObj<typeof Failure> = {
|
||||
render: (args) => {
|
||||
return Failure ? <Failure error={new Error('Oh no')} {...args} /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const success: StoryObj<typeof Success> = {
|
||||
render: (args) => {
|
||||
return Success ? <Success {...standard()} {...args} /> : <></>
|
||||
},
|
||||
}
|
41
web/src/components/PartsCell/PartsCell.test.tsx
Normal file
41
web/src/components/PartsCell/PartsCell.test.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
import { Loading, Empty, Failure, Success } from './PartsCell'
|
||||
import { standard } from './PartsCell.mock'
|
||||
|
||||
// Generated boilerplate tests do not account for all circumstances
|
||||
// and can fail without adjustments, e.g. Float and DateTime types.
|
||||
// Please refer to the RedwoodJS Testing Docs:
|
||||
// https://redwoodjs.com/docs/testing#testing-cells
|
||||
// https://redwoodjs.com/docs/testing#jest-expect-type-considerations
|
||||
|
||||
describe('PartsCell', () => {
|
||||
it('renders Loading successfully', () => {
|
||||
expect(() => {
|
||||
render(<Loading />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders Empty successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Empty />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders Failure successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Failure error={new Error('Oh no')} />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
// When you're ready to test the actual output of your component render
|
||||
// you could test that, for example, certain text is present:
|
||||
//
|
||||
// 1. import { screen } from '@redwoodjs/testing/web'
|
||||
// 2. Add test: expect(screen.getByText('Hello, world')).toBeInTheDocument()
|
||||
|
||||
it('renders Success successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Success parts={standard().parts} />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
238
web/src/components/PartsCell/PartsCell.tsx
Normal file
238
web/src/components/PartsCell/PartsCell.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */
|
||||
import {
|
||||
mdiAlert,
|
||||
mdiChevronRight,
|
||||
mdiChevronLeft,
|
||||
mdiChevronDoubleRight,
|
||||
mdiChevronDoubleLeft,
|
||||
} from '@mdi/js'
|
||||
import { Icon } from '@mdi/react'
|
||||
import type { PartsQuery, SortMethod, SortOrder } from 'types/graphql'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
|
||||
|
||||
import PartsGridUnit from '../PartsGridUnit/PartsGridUnit'
|
||||
|
||||
const POSTS_PER_PAGE = 8
|
||||
|
||||
export const beforeQuery = ({ page, sort, order }) => {
|
||||
page = page ? parseInt(page, 10) : 1
|
||||
sort =
|
||||
sort &&
|
||||
(['createdAt', 'description', 'id', 'name', 'stock'].includes(sort)
|
||||
? sort
|
||||
: 'id')
|
||||
order =
|
||||
order && (['ascending', 'descending'].includes(order) ? order : 'ascending')
|
||||
|
||||
return { variables: { page, sort, order } }
|
||||
}
|
||||
|
||||
export const QUERY = gql`
|
||||
query PartsQuery($page: Int, $sort: SortMethod, $order: SortOrder) {
|
||||
partPage(page: $page, sort: $sort, order: $order) {
|
||||
parts {
|
||||
id
|
||||
name
|
||||
description
|
||||
availableStock
|
||||
imageUrl
|
||||
}
|
||||
count
|
||||
page
|
||||
sort
|
||||
order
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => (
|
||||
<div className="flex w-auto justify-center">
|
||||
<p className="loading loading-bars loading-lg" />
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Empty = () => (
|
||||
<div className="flex justify-center">
|
||||
<div className="alert w-auto">
|
||||
<p className="text-center font-inter">It's empty in here...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Failure = ({ error }: CellFailureProps) => (
|
||||
<div className="flex w-auto justify-center">
|
||||
<div className="alert alert-error w-auto">
|
||||
<Icon path={mdiAlert} className="h-6 w-6" />
|
||||
<p className="font-inter">Error! {error?.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Success = ({ partPage }: CellSuccessProps<PartsQuery>) => {
|
||||
const sortMethodToText = (sortByText: string) => {
|
||||
switch (sortByText as SortMethod) {
|
||||
case 'createdAt':
|
||||
sortByText = 'Created at'
|
||||
break
|
||||
|
||||
case 'description':
|
||||
sortByText = 'Description'
|
||||
break
|
||||
|
||||
case 'id':
|
||||
sortByText = 'ID'
|
||||
break
|
||||
|
||||
case 'name':
|
||||
sortByText = 'Name'
|
||||
break
|
||||
|
||||
case 'stock':
|
||||
sortByText = 'Stock'
|
||||
break
|
||||
}
|
||||
|
||||
return sortByText
|
||||
}
|
||||
|
||||
const sortOrderToText = (orderText: string) => {
|
||||
switch (orderText as SortOrder) {
|
||||
case 'ascending':
|
||||
orderText = 'Ascending'
|
||||
break
|
||||
|
||||
case 'descending':
|
||||
orderText = 'Descending'
|
||||
break
|
||||
}
|
||||
|
||||
return orderText
|
||||
}
|
||||
|
||||
if (partPage.count == 0) return Empty()
|
||||
else {
|
||||
const sortByText: string = sortMethodToText(partPage.sort)
|
||||
const orderText: string = sortOrderToText(partPage.order)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-6">
|
||||
<div className="flex space-x-3 font-inter">
|
||||
<div className="dropdown">
|
||||
<label tabIndex={0} className="btn m-1 normal-case">
|
||||
Sort: {sortByText}
|
||||
</label>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content rounded-box z-[1] mt-3 w-auto space-y-3 bg-base-100 p-3 shadow"
|
||||
>
|
||||
{['id', 'createdAt', 'description', 'name', 'stock'].map(
|
||||
(sort) => (
|
||||
<li key={sort}>
|
||||
<Link
|
||||
className="btn btn-ghost w-full normal-case"
|
||||
to={routes.home({
|
||||
page: partPage.page,
|
||||
sort,
|
||||
order: partPage.order,
|
||||
})}
|
||||
>
|
||||
{sortMethodToText(sort)}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="dropdown">
|
||||
<label tabIndex={0} className="btn m-1 normal-case">
|
||||
Order: {orderText}
|
||||
</label>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content rounded-box z-[1] mt-3 w-auto space-y-3 bg-base-100 p-4 shadow"
|
||||
>
|
||||
{['ascending', 'descending'].map((order) => (
|
||||
<li key={order}>
|
||||
<Link
|
||||
className="btn btn-ghost w-full normal-case"
|
||||
to={routes.home({
|
||||
page: partPage.page,
|
||||
sort: partPage.sort,
|
||||
order,
|
||||
})}
|
||||
>
|
||||
{sortOrderToText(order)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid place-items-center gap-x-6 gap-y-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{partPage.parts.map((part) => (
|
||||
<PartsGridUnit key={part.id} part={part} />
|
||||
))}
|
||||
</div>
|
||||
<div className="join">
|
||||
<Link
|
||||
className={`btn join-item ${
|
||||
partPage.page == 1 ? 'btn-disabled' : ''
|
||||
}`}
|
||||
to={routes.home({
|
||||
page: 1,
|
||||
sort: partPage.sort,
|
||||
order: partPage.order,
|
||||
})}
|
||||
>
|
||||
<Icon path={mdiChevronDoubleLeft} className="h-6 w-6" />
|
||||
</Link>
|
||||
<Link
|
||||
className={`btn join-item ${
|
||||
partPage.page == 1 ? 'btn-disabled' : ''
|
||||
}`}
|
||||
to={routes.home({
|
||||
page: partPage.page - 1,
|
||||
sort: partPage.sort,
|
||||
order: partPage.order,
|
||||
})}
|
||||
>
|
||||
<Icon path={mdiChevronLeft} className="h-6 w-6" />
|
||||
</Link>
|
||||
<p className="btn join-item items-center font-inter normal-case">
|
||||
Page {partPage.page} of {Math.ceil(partPage.count / POSTS_PER_PAGE)}
|
||||
</p>
|
||||
<Link
|
||||
className={`btn join-item ${
|
||||
partPage.page == Math.ceil(partPage.count / POSTS_PER_PAGE)
|
||||
? 'btn-disabled'
|
||||
: ''
|
||||
}`}
|
||||
to={routes.home({
|
||||
page: partPage.page + 1,
|
||||
sort: partPage.sort,
|
||||
order: partPage.order,
|
||||
})}
|
||||
>
|
||||
<Icon path={mdiChevronRight} className="h-6 w-6" />
|
||||
</Link>
|
||||
<Link
|
||||
className={`btn join-item ${
|
||||
partPage.page == Math.ceil(partPage.count / POSTS_PER_PAGE)
|
||||
? 'btn-disabled'
|
||||
: ''
|
||||
}`}
|
||||
to={routes.home({
|
||||
page: Math.ceil(partPage.count / POSTS_PER_PAGE),
|
||||
sort: partPage.sort,
|
||||
order: partPage.order,
|
||||
})}
|
||||
>
|
||||
<Icon path={mdiChevronDoubleRight} className="h-6 w-6" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
25
web/src/components/PartsGridUnit/PartsGridUnit.stories.tsx
Normal file
25
web/src/components/PartsGridUnit/PartsGridUnit.stories.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
// Pass props to your component by passing an `args` object to your story
|
||||
//
|
||||
// ```tsx
|
||||
// export const Primary: Story = {
|
||||
// args: {
|
||||
// propName: propValue
|
||||
// }
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// See https://storybook.js.org/docs/react/writing-stories/args.
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import PartsGridUnit from './PartsGridUnit'
|
||||
|
||||
const meta: Meta<typeof PartsGridUnit> = {
|
||||
component: PartsGridUnit,
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof PartsGridUnit>
|
||||
|
||||
export const Primary: Story = {}
|
14
web/src/components/PartsGridUnit/PartsGridUnit.test.tsx
Normal file
14
web/src/components/PartsGridUnit/PartsGridUnit.test.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
|
||||
import PartsGridUnit from './PartsGridUnit'
|
||||
|
||||
// Improve this test with help from the Redwood Testing Doc:
|
||||
// https://redwoodjs.com/docs/testing#testing-components
|
||||
|
||||
describe('PartsGridUnit', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<PartsGridUnit />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
66
web/src/components/PartsGridUnit/PartsGridUnit.tsx
Normal file
66
web/src/components/PartsGridUnit/PartsGridUnit.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
interface Props {
|
||||
part: {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
availableStock: number
|
||||
imageUrl: string
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnail = (url: string) => {
|
||||
if (url.includes('no_image.png')) return url
|
||||
const parts = url.split('/')
|
||||
parts.splice(3, 0, 'resize=width:384')
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
const PartsGridUnit = ({ part }: Props) => {
|
||||
return (
|
||||
<div className="card-compact card w-72 space-y-3 bg-base-100 font-inter shadow-xl sm:w-96">
|
||||
<figure>
|
||||
<img
|
||||
className="h-48 object-cover"
|
||||
src={thumbnail(part.imageUrl)}
|
||||
width={384}
|
||||
height={128}
|
||||
alt={part.name + ' image'}
|
||||
/>
|
||||
</figure>
|
||||
<div className="card-body">
|
||||
<h2 className="card-title justify-between">
|
||||
<Link
|
||||
className="link-hover link w-auto overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
to={routes.partDetails({ id: part.id })}
|
||||
>
|
||||
{part.name}
|
||||
</Link>
|
||||
{part.availableStock == 0 ? (
|
||||
<div className="badge badge-error">Out of stock</div>
|
||||
) : (
|
||||
<div className="badge badge-ghost whitespace-nowrap">
|
||||
{part.availableStock + ' left'}
|
||||
</div>
|
||||
)}
|
||||
</h2>
|
||||
<p className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{part.description}
|
||||
</p>
|
||||
<div className="card-actions justify-end">
|
||||
<button
|
||||
className={`btn btn-primary ${
|
||||
part.availableStock == 0 ? 'btn-disabled' : ''
|
||||
}`}
|
||||
>
|
||||
Add to basket
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// TODO: add to basket funcionality
|
||||
)
|
||||
}
|
||||
|
||||
export default PartsGridUnit
|
@ -1,13 +1,3 @@
|
||||
/**
|
||||
* START --- SETUP TAILWINDCSS EDIT
|
||||
*
|
||||
* `yarn rw setup ui tailwindcss` placed these directives here
|
||||
* to inject Tailwind's styles into your CSS.
|
||||
* For more information, see: https://tailwindcss.com/docs/installation
|
||||
*/
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/**
|
||||
* END --- SETUP TAILWINDCSS EDIT
|
||||
*/
|
||||
|
@ -3,6 +3,7 @@ import Icon from '@mdi/react'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import { useAuth } from 'src/auth'
|
||||
import NavbarAccountIcon from 'src/components/NavbarAccountIcon/NavbarAccountIcon'
|
||||
import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle'
|
||||
|
||||
@ -11,9 +12,11 @@ type NavBarLayoutProps = {
|
||||
}
|
||||
|
||||
const NavBarLayout = ({ children }: NavBarLayoutProps) => {
|
||||
const { hasRole } = useAuth()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="navbar bg-base-100 shadow-lg">
|
||||
<div className="navbar sticky top-0 z-50 bg-base-100 shadow-lg">
|
||||
<div className="justify-start space-x-3">
|
||||
<Icon
|
||||
path={mdiChip}
|
||||
@ -29,16 +32,20 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="ml-auto justify-end space-x-3">
|
||||
{/* <ul className="relative hidden items-center space-x-3 lg:flex">
|
||||
<li>
|
||||
<Link
|
||||
to={routes.faq()}
|
||||
className="btn btn-ghost font-inter hover:shadow-lg"
|
||||
>
|
||||
FAQ
|
||||
</Link>
|
||||
</li>
|
||||
</ul> */}
|
||||
{hasRole('admin') ? (
|
||||
<ul className="relative hidden items-center space-x-3 lg:flex">
|
||||
<li>
|
||||
<Link
|
||||
to={routes.parts()}
|
||||
className="btn btn-ghost font-inter hover:shadow-lg"
|
||||
>
|
||||
Parts
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
<NavbarAccountIcon mobile={false} className="hidden lg:block" />
|
||||
<div className="lg:hidden">
|
||||
@ -75,14 +82,18 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
|
||||
<NavbarAccountIcon mobile={true} />
|
||||
</div>
|
||||
</li>
|
||||
{/* <li>
|
||||
<Link
|
||||
to={routes.faq()}
|
||||
className="btn btn-ghost w-full hover:shadow-lg"
|
||||
>
|
||||
<p className="font-inter text-base">FAQ</p>
|
||||
</Link>
|
||||
</li> */}
|
||||
{hasRole('admin') ? (
|
||||
<li>
|
||||
<Link
|
||||
to={routes.parts()}
|
||||
className="btn btn-ghost w-full hover:shadow-lg"
|
||||
>
|
||||
<p className="font-inter text-base">Parts</p>
|
||||
</Link>
|
||||
</li>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,3 +1,6 @@
|
||||
import { mdiHome } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import { Toaster } from '@redwoodjs/web/toast'
|
||||
|
||||
@ -20,9 +23,14 @@ const ScaffoldLayout = ({
|
||||
<div className="rw-scaffold">
|
||||
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
|
||||
<header className="rw-header">
|
||||
<h1 className="rw-heading rw-heading-primary rw-button btn-ghost normal-case">
|
||||
<Link to={routes[titleTo]()}>{title}</Link>
|
||||
</h1>
|
||||
<div className="space-x-3">
|
||||
<Link to={routes.home()} className="btn btn-ghost">
|
||||
<Icon path={mdiHome} className="h-8 w-8" />
|
||||
</Link>
|
||||
<h1 className="rw-heading rw-heading-primary rw-button btn-ghost normal-case">
|
||||
<Link to={routes[titleTo]()}>{title}</Link>
|
||||
</h1>
|
||||
</div>
|
||||
<Link to={routes[buttonTo]()} className="rw-button btn-success">
|
||||
<div className="rw-button-icon">+</div> {buttonLabel}
|
||||
</Link>
|
||||
|
@ -22,7 +22,7 @@ const ForgotPasswordPage = () => {
|
||||
}, [])
|
||||
|
||||
const onSubmit = async (data: { email: string }) => {
|
||||
const response = await forgotPassword(data.email)
|
||||
const response = await forgotPassword(data.email.toLowerCase())
|
||||
|
||||
if (response.error) {
|
||||
toast.error(response.error)
|
||||
|
@ -1,19 +1,27 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import { SortMethod, SortOrder } from 'types/graphql'
|
||||
|
||||
import { MetaTags } from '@redwoodjs/web'
|
||||
|
||||
const HomePage = () => {
|
||||
import PartsCell from 'src/components/PartsCell'
|
||||
|
||||
interface Props {
|
||||
page: number
|
||||
sort: SortMethod
|
||||
order: SortOrder
|
||||
}
|
||||
|
||||
const HomePage = ({ page = 1, sort = 'id', order = 'ascending' }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<MetaTags title="Home" description="Home page" />
|
||||
|
||||
<h1>HomePage</h1>
|
||||
<p>
|
||||
Find me in <code>./web/src/pages/HomePage/HomePage.tsx</code>
|
||||
</p>
|
||||
<p>
|
||||
My default route is named <code>home</code>, link to me with `
|
||||
<Link to={routes.home()}>Home</Link>`
|
||||
</p>
|
||||
<div className="my-16 text-center font-inter md:my-24">
|
||||
<h1 className="text-4xl font-extrabold tracking-wide md:text-6xl">
|
||||
Arduino Parts Inventory
|
||||
</h1>
|
||||
<p className="pt-4 text-xl">Only take what you need</p>
|
||||
</div>
|
||||
<PartsCell page={page} sort={sort} order={order} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -30,7 +30,7 @@ const LoginPage = () => {
|
||||
|
||||
const onSubmit = async (data: Record<string, string>) => {
|
||||
const response = await logIn({
|
||||
username: data.email,
|
||||
username: data.email.toLowerCase(),
|
||||
password: data.password,
|
||||
})
|
||||
|
||||
|
13
web/src/pages/PartPage/PartPage.stories.tsx
Normal file
13
web/src/pages/PartPage/PartPage.stories.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import PartPage from './PartPage'
|
||||
|
||||
const meta: Meta<typeof PartPage> = {
|
||||
component: PartPage,
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof PartPage>
|
||||
|
||||
export const Primary: Story = {}
|
14
web/src/pages/PartPage/PartPage.test.tsx
Normal file
14
web/src/pages/PartPage/PartPage.test.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
|
||||
import PartPage from './PartPage'
|
||||
|
||||
// Improve this test with help from the Redwood Testing Doc:
|
||||
// https://redwoodjs.com/docs/testing#testing-pages-layouts
|
||||
|
||||
describe('PartPage', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<PartPage />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
21
web/src/pages/PartPage/PartPage.tsx
Normal file
21
web/src/pages/PartPage/PartPage.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { MetaTags } from '@redwoodjs/web'
|
||||
|
||||
import PartDetailsCell from 'src/components/PartDetailsCell'
|
||||
|
||||
interface Props {
|
||||
id: number
|
||||
}
|
||||
|
||||
const PartPage = ({ id }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<MetaTags title="Part" description="Part page" />
|
||||
|
||||
<div className="m-8">
|
||||
<PartDetailsCell id={id} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PartPage
|
@ -31,7 +31,7 @@ const SignupPage = () => {
|
||||
|
||||
const onSubmit = async (data: Record<string, string>) => {
|
||||
const response = await signUp({
|
||||
username: data.email,
|
||||
username: data.email.toLowerCase(),
|
||||
password: data.password,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
@ -114,6 +114,7 @@ const SignupPage = () => {
|
||||
message: 'Email is not valid',
|
||||
},
|
||||
}}
|
||||
inputMode="email"
|
||||
/>
|
||||
<FieldError name="email" className="rw-field-error pb-3" />
|
||||
|
||||
|
@ -107,7 +107,7 @@
|
||||
@apply input input-bordered w-full max-w-xs font-inter text-base-content;
|
||||
}
|
||||
.rw-textarea {
|
||||
@apply textarea textarea-bordered font-inter max-w-xs w-full text-base-content;
|
||||
@apply textarea textarea-bordered text-base font-inter max-w-xs w-full text-base-content;
|
||||
}
|
||||
.rw-check-radio-items {
|
||||
@apply flex justify-items-center;
|
||||
|
Reference in New Issue
Block a user