1
0

Basic transaction system

This commit is contained in:
Ahmed Al-Taiar
2023-11-14 18:54:44 -05:00
parent f6f01594ec
commit 8060e1e452
60 changed files with 2037 additions and 507 deletions

View File

@ -13,10 +13,11 @@
"dependencies": {
"@mdi/js": "^7.3.67",
"@mdi/react": "^1.6.1",
"@redwoodjs/auth-dbauth-web": "6.3.2",
"@redwoodjs/forms": "6.3.2",
"@redwoodjs/router": "6.3.2",
"@redwoodjs/web": "6.3.2",
"@redwoodjs/auth-dbauth-web": "6.3.3",
"@redwoodjs/forms": "6.3.3",
"@redwoodjs/router": "6.3.3",
"@redwoodjs/web": "6.3.3",
"dayjs": "^1.11.10",
"filestack-react": "^4.0.1",
"humanize-string": "2.1.0",
"prop-types": "15.8.1",
@ -25,7 +26,7 @@
"theme-change": "^2.5.0"
},
"devDependencies": {
"@redwoodjs/vite": "6.3.2",
"@redwoodjs/vite": "6.3.3",
"@types/filestack-react": "^4.0.3",
"@types/node": "^20.8.9",
"@types/react": "18.2.14",

View File

@ -20,12 +20,18 @@ const Routes = () => {
<Route path="/admin/parts/{id:Int}" page={PartPartPage} name="part" />
<Route path="/admin/parts" page={PartPartsPage} name="parts" />
</Set>
<Set wrap={ScaffoldLayout} title="Transactions" titleTo="adminTransactions">
<Route path="/admin/transactions" page={AdminTransactionsPage} name="adminTransactions" />
</Set>
</Private>
<Set wrap={NavbarLayout}>
<Route path="/" page={HomePage} name="home" />
<Route path="/part/{id:Int}" page={PartPage} name="partDetails" />
<Route path="/basket" page={BasketPage} name="basket" />
<Private unauthenticated="login">
<Route path="/transactions" page={TransactionsPage} name="userTransactions" />
</Private>
</Set>
<Route notfound page={NotFoundPage} />

View 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 AdminMenu from './AdminMenu'
const meta: Meta<typeof AdminMenu> = {
component: AdminMenu,
}
export default meta
type Story = StoryObj<typeof AdminMenu>
export const Primary: Story = {}

View File

@ -0,0 +1,14 @@
import { render } from '@redwoodjs/testing/web'
import AdminMenu from './AdminMenu'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-components
describe('AdminMenu', () => {
it('renders successfully', () => {
expect(() => {
render(<AdminMenu />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,53 @@
import { mdiWrench } from '@mdi/js'
import Icon from '@mdi/react'
import { Link, routes } from '@redwoodjs/router'
import { useAuth } from 'src/auth'
interface Props {
mobile: boolean
className?: string
}
const AdminMenu = ({ mobile, className }: Props) => {
const { isAuthenticated, hasRole } = useAuth()
return isAuthenticated && hasRole('admin') ? (
<div className={className}>
<details
className={`dropdown ${
mobile ? 'dropdown-start space-y-2' : 'dropdown-end space-y-4'
}`}
>
<summary className="btn btn-ghost swap swap-rotate w-12 hover:shadow-lg">
<Icon path={mdiWrench} className="h-8 w-8 text-base-content" />
</summary>
<div className="dropdown-content flex flex-col items-center space-y-3 rounded-xl bg-base-100 p-3 shadow-lg">
<ul className="space-y-3 bg-base-100 text-base-content">
<li>
<Link
to={routes.parts()}
className="btn btn-ghost w-full font-inter hover:shadow-lg"
>
Parts
</Link>
</li>
<li>
<Link
to={routes.adminTransactions()}
className="btn btn-ghost w-full hover:shadow-lg"
>
<p className="font-inter">Transactions</p>
</Link>
</li>
</ul>
</div>
</details>
</div>
) : (
<></>
)
}
export default AdminMenu

View File

@ -0,0 +1,4 @@
// Define your own mock data here:
export const standard = (/* vars, { ctx, req } */) => ({
userTransactions: [{ id: 42 }, { id: 43 }, { id: 44 }],
})

View File

@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Loading, Empty, Failure, Success } from './UserTransactionsCell'
import { standard } from './UserTransactionsCell.mock'
const meta: Meta = {
title: 'Cells/UserTransactionsCell',
}
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} /> : <></>
},
}

View File

@ -0,0 +1,42 @@
import { render } from '@redwoodjs/testing/web'
import { Loading, Empty, Failure, Success } from './AdminTransactionsCell'
import { standard } from './AdminTransactionsCell.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('UserTransactionsCell', () => {
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 userTransactions={standard().userTransactions} />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,104 @@
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */
import { mdiAlert } from '@mdi/js'
import { Icon } from '@mdi/react'
import type { TransactionsQuery } from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
import TransactionListItem from '../TransactionListItem/TransactionListItem'
export const beforeQuery = ({ filter }) => {
filter = filter && ['both', 'in', 'out'].includes(filter) ? filter : 'both'
return { variables: { filter } }
}
export const QUERY = gql`
query TransactionsQuery($filter: FilterTransactionsByType!) {
transactions(filter: $filter) {
transactions {
id
date
parts
type
user {
firstName
lastName
}
}
filter
}
}
`
export const Loading = () => (
<div className="flex w-auto justify-center">
<p className="loading loading-bars loading-lg" />
</div>
)
export const Empty = () => (
<div className="flex">
<div className="alert w-auto">
<p className="text-center font-inter">It&#39;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 = ({
transactions,
}: CellSuccessProps<TransactionsQuery>) => {
if (transactions.transactions.length == 0) return Empty()
return (
<div className="flex flex-col space-y-6 font-inter">
<div className="dropdown">
<label tabIndex={0} className="btn m-1 normal-case">
Filter:{' '}
{transactions.filter == 'both'
? 'None'
: transactions.filter.replace(
transactions.filter[0],
transactions.filter[0].toUpperCase()
)}
</label>
<ul
tabIndex={0}
className="dropdown-content rounded-box z-10 mt-3 w-auto space-y-3 bg-base-100 p-3 shadow"
>
{['None', 'In', 'Out'].map((filter) => (
<li key={filter}>
<Link
className="btn btn-ghost w-full normal-case"
to={routes.adminTransactions({
filter: filter === 'None' ? 'both' : filter.toLowerCase(),
})}
>
{filter}
</Link>
</li>
))}
</ul>
</div>
{transactions.transactions.map((item, i) => {
return (
<TransactionListItem
transaction={item}
key={i}
returnButton={false}
admin
/>
)
})}
</div>
)
}

View File

@ -0,0 +1,6 @@
// Define your own mock data here:
export const standard = (/* vars, { ctx, req } */) => ({
basket: {
id: 42,
},
})

View File

@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Loading, Empty, Failure, Success } from './BasketCell'
import { standard } from './BasketCell.mock'
const meta: Meta = {
title: 'Cells/BasketCell',
}
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} /> : <></>
},
}

View File

@ -0,0 +1,41 @@
import { render } from '@redwoodjs/testing/web'
import { Loading, Empty, Failure, Success } from './BasketCell'
import { standard } from './BasketCell.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('BasketCell', () => {
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 basket={standard().basket} />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,207 @@
import { useState } from 'react'
import { mdiMinus, mdiPlus, mdiDelete } from '@mdi/js'
import Icon from '@mdi/react'
import type { CreateTransactionInput } from 'types/graphql'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/dist/toast'
import { useAuth } from 'src/auth'
import {
getBasket,
setBasket,
removeFromBasket,
clearBasket,
} from 'src/lib/basket'
import ToastNotification from '../ToastNotification'
export const CREATE_TRANSACTION_MUTATION = gql`
mutation CreateTransactionMutation($input: CreateTransactionInput!) {
createTransaction(input: $input) {
id
}
}
`
const thumbnail = (url: string) => {
if (url.includes('no_image.png')) return url
const parts = url.split('/')
parts.splice(3, 0, 'resize=width:160')
return parts.join('/')
}
export const BasketCell = () => {
const { isAuthenticated, currentUser } = useAuth()
const [basket, setBasketState] = useState(getBasket())
const [createTransaction, { loading }] = useMutation(
CREATE_TRANSACTION_MUTATION,
{
onCompleted: () => {
toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message="Transaction complete"
/>
))
setBasketState(clearBasket())
},
onError: (error) => {
toast.custom((t) => (
<ToastNotification toast={t} type="error" message={error.message} />
))
},
}
)
const submitBasket = (input: CreateTransactionInput) => {
createTransaction({ variables: { input } })
}
return (
<div className="space-y-3">
{basket.length > 0 ? (
basket.map((item, i) => (
<div
key={i}
className="flex max-w-5xl items-center rounded-xl bg-base-200 shadow-xl"
>
<img
alt={item.part.name}
className="hidden h-20 w-20 rounded-l-xl object-cover sm:flex"
src={thumbnail(item.part.imageUrl)}
/>
<div className="m-3 w-full items-center justify-between space-y-3 sm:flex sm:space-y-0">
<p className="overflow-hidden text-ellipsis whitespace-nowrap font-inter text-lg font-bold">
{item.part.name}
</p>
<div className="flex justify-between space-x-3">
<div className="join">
<button
className={`btn join-item ${
item.quantity <= 1 ? 'btn-disabled' : ''
}`}
onClick={() => {
const newBasket = basket
newBasket[i].quantity -= 1
setBasketState(setBasket(newBasket))
}}
>
<Icon path={mdiMinus} className="h-6 w-6" />
</button>
<p className="btn join-item items-center font-inter text-lg">
{item.quantity}
</p>
<button
className={`btn join-item ${
item.quantity >= item.part.availableStock
? 'btn-disabled'
: ''
}`}
onClick={() => {
const newBasket = basket
newBasket[i].quantity += 1
setBasketState(setBasket(newBasket))
}}
>
<Icon path={mdiPlus} className="h-6 w-6" />
</button>
</div>
<button
className="btn btn-ghost hover:shadow-lg"
onClick={() => {
const newBasket = removeFromBasket(i)
if (typeof newBasket == 'string')
toast.custom((t) => (
<ToastNotification
toast={t}
type="error"
message={newBasket}
/>
))
else {
setBasketState(newBasket)
toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message={`Removed ${item.part.name} from basket`}
/>
))
}
}}
>
<Icon
path={mdiDelete}
className="h-8 w-8 text-base-content"
/>
</button>
</div>
</div>
</div>
))
) : (
<div className="flex">
<div className="alert w-auto shadow-lg">
<p className="text-center font-inter">It&#39;s empty in here...</p>
</div>
</div>
)}
{basket.length > 0 ? (
<div className="flex space-x-3 pt-3">
<button
onClick={() => {
setBasketState(clearBasket())
toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message="Basket cleared"
/>
))
}}
className="btn font-inter"
>
Clear basket
</button>
<button
disabled={loading}
onClick={() => {
if (!isAuthenticated)
toast.custom((t) => (
<ToastNotification
toast={t}
type="error"
message="You must be logged in to do that"
/>
))
else {
submitBasket({
date: new Date().toISOString(),
type: 'out',
userId: currentUser.id,
parts: basket.map((item) => ({
part: JSON.stringify(item.part),
quantity: item.quantity,
})),
})
}
}}
className={`btn btn-primary font-inter ${
loading ? 'btn-disabled' : ''
}`}
>
Checkout
</button>
</div>
) : (
<></>
)}
</div>
)
}

View File

@ -40,18 +40,25 @@ const Part = ({ part }: Props) => {
}
}
const preview = (url: string) => {
if (url.includes('no_image.png')) return url
const parts = url.split('/')
parts.splice(3, 0, 'resize=height:500')
return parts.join('/')
}
return (
<>
<div className="rw-segment">
<div className="rw-segment font-inter">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">
Part {part.id} Detail
Part {part.id} details
</h2>
</header>
<table className="rw-table">
<tbody>
<tr>
<th>Id</th>
<th>ID</th>
<td>{part.id}</td>
</tr>
<tr>
@ -68,7 +75,13 @@ const Part = ({ part }: Props) => {
</tr>
<tr>
<th>Image</th>
<td>{part.imageUrl}</td>
<td>
<img
alt=""
src={preview(part.imageUrl)}
style={{ display: 'block', margin: '2rem 0' }}
/>
</td>
</tr>
<tr>
<th>Created at</th>

View File

@ -28,11 +28,11 @@ const PartForm = (props: PartFormProps) => {
const [imageUrl, setImageUrl] = useState(props?.part?.imageUrl)
const onSubmit = (data: FormPart) => {
console.log(imageUrl)
const dataWithImageUrl = Object.assign(data, {
const dataUpdated = Object.assign(data, {
imageUrl: imageUrl ?? '/no_image.png',
})
props.onSave(dataWithImageUrl, props?.part?.id)
props.onSave(dataUpdated, props?.part?.id)
}
const onImageUpload = (response) => {

View File

@ -1,6 +1,7 @@
import { mdiAlert } from '@mdi/js'
import Icon from '@mdi/react'
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'
@ -18,21 +19,27 @@ export const QUERY = gql`
}
`
export const Loading = () => <div>Loading...</div>
export const Loading = () => (
<div className="flex w-auto justify-center">
<p className="loading loading-bars loading-lg" />
</div>
)
export const Empty = () => {
return (
<div className="rw-text-center p-4">
<span className="font-inter">No parts yet.</span>{' '}
<Link to={routes.newPart()} className="rw-link">
{'Create one?'}
</Link>
export const Empty = () => (
<div className="flex justify-center">
<div className="alert w-auto">
<p className="text-center font-inter">It&#39;s empty in here...</p>
</div>
)
}
</div>
)
export const Failure = ({ error }: CellFailureProps) => (
<div className="rw-cell-error">{error?.message}</div>
<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 = ({ parts }: CellSuccessProps<FindParts>) => {

View File

@ -1,3 +1,5 @@
import type { Part } from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
import { toast } from '@redwoodjs/web/toast'
@ -6,14 +8,7 @@ import { addToBasket } from 'src/lib/basket'
import ToastNotification from '../ToastNotification'
interface Props {
part: {
id: number
name: string
description?: string
availableStock: number
imageUrl: string
createdAt: string
}
part: Part
}
const thumbnail = (url: string) => {
@ -25,66 +20,66 @@ const thumbnail = (url: string) => {
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 })}
>
<Link to={routes.partDetails({ id: part.id })}>
<div className="card-compact card w-72 space-y-3 bg-base-100 font-inter shadow-xl transition-all duration-200 hover:-translate-y-2 hover:shadow-2xl 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">
{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' : ''
}`}
onClick={() => {
const newBasket = addToBasket(part, 1)
{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' : ''
}`}
onClick={(event) => {
event.stopPropagation()
event.preventDefault()
if (typeof newBasket == 'string')
toast.custom((t) => (
<ToastNotification
toast={t}
type="error"
message={newBasket}
/>
))
else
toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message={`Added 1 ${part.name} to basket`}
/>
))
}}
>
Add to basket
</button>
const newBasket = addToBasket(part, 1)
if (typeof newBasket == 'string')
toast.custom((t) => (
<ToastNotification
toast={t}
type="error"
message={newBasket}
/>
))
else
toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message={`Added 1 ${part.name} to basket`}
/>
))
}}
>
Add to basket
</button>
</div>
</div>
</div>
</div>
</Link>
)
}

View File

@ -13,12 +13,12 @@ const ThemeToggle = () => {
useEffect(() => {
themeChange(false)
return () => {
themeChange(false)
themeChange(true)
}
}, [])
return (
<label className="btn btn-ghost swap swap-rotate w-12 hover:shadow-lg">
<label className="swap-rotate btn btn-ghost swap w-12 hover:shadow-lg">
<input
type="checkbox"
defaultChecked={isDark}

View File

@ -35,7 +35,7 @@ const ToastNotification = ({ type, message, toast }: Props) => (
: mdiInformation
}
/>
<p className="font-inter">{message}</p>
<p className="m-3 ml-0 font-inter">{message}</p>
</div>
)

View 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 TransactionListItem from './TransactionListItem'
const meta: Meta<typeof TransactionListItem> = {
component: TransactionListItem,
}
export default meta
type Story = StoryObj<typeof TransactionListItem>
export const Primary: Story = {}

View File

@ -0,0 +1,14 @@
import { render } from '@redwoodjs/testing/web'
import TransactionListItem from './TransactionListItem'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-components
describe('TransactionListItem', () => {
it('renders successfully', () => {
expect(() => {
render(<TransactionListItem />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,137 @@
import { Prisma } from '@prisma/client'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import type { Part, Transaction, TransactionType } from 'types/graphql'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/dist/toast'
import { useAuth } from 'src/auth'
import ToastNotification from '../ToastNotification'
interface Props {
transaction:
| {
id: number
date: string
parts: Prisma.JsonValue[]
type: TransactionType
}
| Transaction
returnButton?: boolean
admin?: boolean
}
dayjs.extend(relativeTime)
export const UPDATE_TRANSACTION_MUTATION = gql`
mutation UpdateTransactionMutation($id: Int!, $userId: Int!) {
returnTransaction(id: $id, userId: $userId) {
id
}
}
`
const TransactionListItem = ({
transaction,
returnButton = true,
admin = false,
}: Props) => {
const elapsedTime = dayjs(transaction.date).fromNow()
const [returnTransaction, { loading }] = useMutation(
UPDATE_TRANSACTION_MUTATION,
{
onCompleted: () => {
toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message="Transaction updated"
/>
))
},
onError: (error) => {
toast.custom((t) => (
<ToastNotification toast={t} type="error" message={error.message} />
))
},
}
)
const { currentUser } = useAuth()
return (
<div className="collapse-arrow collapse max-w-5xl bg-base-200 font-inter">
<input type="checkbox" />
<div className="collapse-title text-xl font-medium">
<div className="items-center justify-between space-x-3 space-y-3 sm:flex sm:space-y-0">
<p className="overflow-hidden text-ellipsis whitespace-nowrap">
{transaction.parts.length} items
</p>
<div className="flex w-fit space-x-3">
{admin ? (
<div className="badge whitespace-nowrap bg-base-300 sm:badge-lg">{`${
(transaction as Transaction).user?.firstName
} ${(transaction as Transaction).user?.lastName}`}</div>
) : (
<></>
)}
<div
className={`badge sm:badge-lg ${
transaction.type == 'out' ? 'badge-error' : 'badge-success'
}`}
>
{transaction.type.replace(
transaction.type[0],
transaction.type[0].toUpperCase()
)}
</div>
<div className="badge whitespace-nowrap bg-base-300 sm:badge-lg">
{elapsedTime.replace(
elapsedTime[0],
elapsedTime[0].toUpperCase()
)}
</div>
</div>
</div>
</div>
<div className="collapse-content space-y-3">
<ul className="mx-4 list-disc space-y-2">
{transaction.parts.map((raw) => {
const part = JSON.parse(raw['part']) as Part
const quantity = raw['quantity']
return (
<li className="list-item" key={part.id}>
<div className="flex justify-between space-x-3">
<p>{part.name}</p>
<div className="badge badge-info">
<p>Quantity: {quantity}</p>
</div>
</div>
</li>
)
})}
</ul>
{transaction.type == 'out' && returnButton ? (
<button
className={`btn btn-primary ${loading ? 'btn-disabled' : ''}`}
disabled={loading}
onClick={() =>
returnTransaction({
variables: { id: transaction.id, userId: currentUser.id },
})
}
>
Return
</button>
) : (
<></>
)}
</div>
</div>
)
}
export default TransactionListItem

View File

@ -0,0 +1,4 @@
// Define your own mock data here:
export const standard = (/* vars, { ctx, req } */) => ({
userTransactions: [{ id: 42 }, { id: 43 }, { id: 44 }],
})

View File

@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Loading, Empty, Failure, Success } from './UserTransactionsCell'
import { standard } from './UserTransactionsCell.mock'
const meta: Meta = {
title: 'Cells/UserTransactionsCell',
}
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} /> : <></>
},
}

View File

@ -0,0 +1,41 @@
import { render } from '@redwoodjs/testing/web'
import { Loading, Empty, Failure, Success } from './UserTransactionsCell'
import { standard } from './UserTransactionsCell.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('UserTransactionsCell', () => {
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 userTransactions={standard().userTransactions} />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,97 @@
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */
import { mdiAlert } from '@mdi/js'
import { Icon } from '@mdi/react'
import type { UserTransactionsQuery } from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
import TransactionListItem from '../TransactionListItem/TransactionListItem'
export const beforeQuery = ({ filter, userId }) => {
userId = userId ?? null
filter = filter && ['both', 'in', 'out'].includes(filter) ? filter : 'both'
return { variables: { filter, userId } }
}
export const QUERY = gql`
query UserTransactionsQuery(
$userId: Int!
$filter: FilterTransactionsByType!
) {
userTransactions(userId: $userId, filter: $filter) {
transactions {
id
date
parts
type
}
filter
}
}
`
export const Loading = () => (
<div className="flex w-auto justify-center">
<p className="loading loading-bars loading-lg" />
</div>
)
export const Empty = () => (
<div className="flex">
<div className="alert w-auto">
<p className="text-center font-inter">It&#39;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 = ({
userTransactions,
}: CellSuccessProps<UserTransactionsQuery>) => {
if (userTransactions.transactions.length == 0) return Empty()
return (
<div className="flex flex-col space-y-6 font-inter">
<div className="dropdown">
<label tabIndex={0} className="btn m-1 normal-case">
Filter:{' '}
{userTransactions.filter == 'both'
? 'None'
: userTransactions.filter.replace(
userTransactions.filter[0],
userTransactions.filter[0].toUpperCase()
)}
</label>
<ul
tabIndex={0}
className="dropdown-content rounded-box z-10 mt-3 w-auto space-y-3 bg-base-100 p-3 shadow"
>
{['None', 'In', 'Out'].map((filter) => (
<li key={filter}>
<Link
className="btn btn-ghost w-full normal-case"
to={routes.userTransactions({
filter: filter === 'None' ? 'both' : filter.toLowerCase(),
})}
>
{filter}
</Link>
</li>
))}
</ul>
</div>
{userTransactions.transactions.map((item, i) => {
return <TransactionListItem transaction={item} key={i} />
})}
</div>
)
}

View File

@ -7,6 +7,7 @@ import { Link, routes } from '@redwoodjs/router'
import { Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth'
import AdminMenu from 'src/components/AdminMenu/AdminMenu'
import NavbarAccountIcon from 'src/components/NavbarAccountIcon/NavbarAccountIcon'
import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle'
import { getBasket } from 'src/lib/basket'
@ -38,20 +39,16 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
</Link>
</div>
<div className="ml-auto justify-end space-x-3">
{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>
) : (
<></>
)}
<ul className="relative hidden items-center space-x-3 lg:flex">
<li>
<Link
to={routes.userTransactions()}
className="btn btn-ghost font-inter hover:shadow-lg"
>
Transactions
</Link>
</li>
</ul>
<ThemeToggle />
<Link
to={routes.basket()}
@ -70,6 +67,7 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
</div>
</Link>
<NavbarAccountIcon mobile={false} className="hidden lg:block" />
<AdminMenu mobile={false} className="hidden lg:block" />
<div className="lg:hidden">
<input
id="mobile-menu-drawer"
@ -92,7 +90,11 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
<ul className="min-h-full w-80 space-y-3 bg-base-100 p-3 text-base-content shadow-lg">
<li>
<div className="flex items-center justify-between">
<Icon path={mdiChip} className="ml-3 h-10 text-logo" />
{hasRole('admin') ? (
<AdminMenu mobile={true} />
) : (
<Icon path={mdiChip} className="ml-3 h-10 text-logo" />
)}
<Link
to={routes.home()}
className="btn btn-ghost items-center hover:shadow-lg"
@ -104,18 +106,6 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
<NavbarAccountIcon mobile={true} />
</div>
</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>
) : (
<></>
)}
<li>
<Link
to={routes.basket()}
@ -124,6 +114,14 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
<p className="font-inter text-base">Basket</p>
</Link>
</li>
<li>
<Link
to={routes.userTransactions()}
className="btn btn-ghost w-full hover:shadow-lg"
>
<p className="font-inter text-base">Transactions</p>
</Link>
</li>
</ul>
</div>
</div>

View File

@ -7,8 +7,8 @@ import { Toaster } from '@redwoodjs/web/toast'
type LayoutProps = {
title: string
titleTo: string
buttonLabel: string
buttonTo: string
buttonLabel?: string
buttonTo?: string
children: React.ReactNode
}
@ -24,16 +24,20 @@ const ScaffoldLayout = ({
<Toaster />
<header className="rw-header">
<div className="space-x-3">
<Link to={routes.home()} className="btn btn-ghost">
<Link to={routes.home()} className="btn btn-ghost hover:shadow-lg">
<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>
{buttonLabel && buttonTo ? (
<Link to={routes[buttonTo]()} className="rw-button btn-success">
<div className="rw-button-icon">+</div> {buttonLabel}
</Link>
) : (
<></>
)}
</header>
<main className="rw-main">{children}</main>
</div>

View File

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react'
import AdminTransactionsPage from './AdminTransactionsPage'
const meta: Meta<typeof AdminTransactionsPage> = {
component: AdminTransactionsPage,
}
export default meta
type Story = StoryObj<typeof AdminTransactionsPage>
export const Primary: Story = {}

View File

@ -0,0 +1,14 @@
import { render } from '@redwoodjs/testing/web'
import AdminTransactionsPage from './AdminTransactionsPage'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-pages-layouts
describe('AdminTransactionsPage', () => {
it('renders successfully', () => {
expect(() => {
render(<AdminTransactionsPage />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,50 @@
import { mdiAlert } from '@mdi/js'
import Icon from '@mdi/react'
import { FilterTransactionsByType } from 'types/graphql'
import { MetaTags } from '@redwoodjs/web'
import { useAuth } from 'src/auth'
import AdminTransactionsCell from 'src/components/AdminTransactionsCell'
interface Props {
filter: FilterTransactionsByType
}
const AdminTransactionsPage = ({ filter = 'both' }: Props) => {
const { isAuthenticated, hasRole } = useAuth()
return (
<>
<MetaTags
title="Admin Transactions"
description="Admin Transactions page"
/>
<div className="m-8">
<p className="mb-8 font-inter text-3xl font-bold">Transactions</p>
{isAuthenticated ? (
hasRole('admin') ? (
<AdminTransactionsCell filter={filter} />
) : (
<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">You must be admin to do that.</p>
</div>
</div>
)
) : (
<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">You must be logged in to do that.</p>
</div>
</div>
)}
</div>
</>
)
}
export default AdminTransactionsPage

View File

@ -1,169 +1,15 @@
import { useState } from 'react'
import { mdiDelete, mdiMinus, mdiPlus } from '@mdi/js'
import Icon from '@mdi/react'
import { MetaTags } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth'
import ToastNotification from 'src/components/ToastNotification'
import {
clearBasket,
getBasket,
removeFromBasket,
setBasket,
} from 'src/lib/basket'
const thumbnail = (url: string) => {
if (url.includes('no_image.png')) return url
const parts = url.split('/')
parts.splice(3, 0, 'resize=width:160')
return parts.join('/')
}
import { BasketCell } from 'src/components/BasketCell/BasketCell'
const BasketPage = () => {
const { isAuthenticated } = useAuth()
const [basket, setBasketState] = useState(getBasket())
return (
<>
<MetaTags title="Basket" description="Basket page" />
<div className="m-8">
<h1 className="mb-8 font-inter text-3xl font-bold">Basket</h1>
<div className="space-y-3">
{basket.length > 0 ? (
basket.map((item, i) => (
<div
key={i}
className="flex max-w-5xl items-center rounded-xl bg-base-100 shadow-xl"
>
<img
alt={item.part.name}
className="hidden h-20 w-20 rounded-l-xl object-cover sm:flex"
src={thumbnail(item.part.imageUrl)}
/>
<div className="m-3 w-full items-center justify-between space-y-3 sm:flex sm:space-y-0">
<p className="overflow-hidden text-ellipsis whitespace-nowrap font-inter text-lg font-bold">
{item.part.name}
</p>
<div className="flex justify-between space-x-3">
<div className="join">
<button
className={`btn join-item ${
item.quantity == 1 ? 'btn-disabled' : ''
}`}
onClick={() => {
const newBasket = basket
newBasket[i].quantity -= 1
setBasketState(setBasket(newBasket))
}}
>
<Icon path={mdiMinus} className="h-6 w-6" />
</button>
<p className="btn join-item items-center font-inter text-lg">
{item.quantity}
</p>
<button
className={`btn join-item ${
item.quantity == item.part.availableStock
? 'btn-disabled'
: ''
}`}
onClick={() => {
const newBasket = basket
newBasket[i].quantity += 1
setBasketState(setBasket(newBasket))
}}
>
<Icon path={mdiPlus} className="h-6 w-6" />
</button>
</div>
<button
className="btn btn-ghost hover:shadow-lg"
onClick={() => {
const newBasket = removeFromBasket(i)
if (typeof newBasket == 'string')
toast.custom((t) => (
<ToastNotification
toast={t}
type="error"
message={newBasket}
/>
))
else {
setBasketState(newBasket)
toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message={`Removed ${item.part.name} from basket`}
/>
))
}
}}
>
<Icon
path={mdiDelete}
className="h-8 w-8 text-base-content"
/>
</button>
</div>
</div>
</div>
))
) : (
<div className="flex">
<div className="alert w-auto shadow-lg">
<p className="text-center font-inter">
It&#39;s empty in here...
</p>
</div>
</div>
)}
{basket.length > 0 ? (
<div className="flex space-x-3 pt-3">
<button
onClick={() => {
setBasketState(clearBasket())
toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message="Basket cleared"
/>
))
}}
className="btn font-inter"
>
Clear basket
</button>
<button
onClick={() => {
if (!isAuthenticated)
toast.custom((t) => (
<ToastNotification
toast={t}
type="error"
message="You must be logged in to do that"
/>
))
else {
toast.custom((t) => (
<ToastNotification toast={t} type="info" message="Todo" />
))
}
}}
className="btn btn-primary font-inter"
>
Checkout
</button>
</div>
) : (
<></>
)}
</div>
<BasketCell />
</div>
</>
)

View File

@ -33,6 +33,7 @@ const ForgotPasswordPage = () => {
// The function `forgotPassword.handler` in api/src/functions/auth.js has
// been invoked, let the user know how to get the link to reset their
// password (sent in email, perhaps?)
// TODO: forgot password
toast.custom((t) => (
<ToastNotification
toast={t}

View File

@ -17,7 +17,7 @@ const HomePage = ({ page = 1, sort = 'id', order = 'ascending' }: Props) => {
<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
Parts Inventory
</h1>
<p className="pt-4 text-xl">Only take what you need</p>
</div>

View File

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react'
import TransactionsPage from './TransactionsPage'
const meta: Meta<typeof TransactionsPage> = {
component: TransactionsPage,
}
export default meta
type Story = StoryObj<typeof TransactionsPage>
export const Primary: Story = {}

View File

@ -0,0 +1,14 @@
import { render } from '@redwoodjs/testing/web'
import TransactionsPage from './TransactionsPage'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-pages-layouts
describe('TransactionsPage', () => {
it('renders successfully', () => {
expect(() => {
render(<TransactionsPage />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,42 @@
import { mdiAlert } from '@mdi/js'
import Icon from '@mdi/react'
import { FilterTransactionsByType } from 'types/graphql'
import { MetaTags } from '@redwoodjs/web'
import { useAuth } from 'src/auth'
import UserTransactionsCell from 'src/components/UserTransactionsCell'
interface Props {
filter: FilterTransactionsByType
}
const TransactionsPage = ({ filter = 'both' }: Props) => {
const { isAuthenticated, currentUser } = useAuth()
return (
<>
<MetaTags title="Transactions" description="Transactions page" />
<div className="m-8">
<h1 className="mb-8 font-inter text-3xl font-bold">
Transaction History
</h1>
{isAuthenticated ? (
<UserTransactionsCell
filter={filter}
userId={currentUser ? currentUser.id : -1}
/>
) : (
<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">You must be logged in to do that.</p>
</div>
</div>
)}
</div>
</>
)
}
export default TransactionsPage

View File

@ -5,9 +5,6 @@
.rw-scaffold h2 {
@apply m-0;
}
.rw-scaffold ul {
@apply m-0 p-0;
}
.rw-scaffold input:-ms-input-placeholder {
@apply text-gray-500;
}