1
0

Parts schema and scaffolld, with file uploading for the image (through Filestack)

This commit is contained in:
Ahmed Al-Taiar
2023-10-27 13:25:08 -04:00
parent 2f753308b1
commit 1eaf76fce2
31 changed files with 1492 additions and 13 deletions

View File

@ -16,6 +16,8 @@
"@redwoodjs/forms": "6.3.2",
"@redwoodjs/router": "6.3.2",
"@redwoodjs/web": "6.3.2",
"filestack-react": "^4.0.1",
"humanize-string": "2.1.0",
"prop-types": "15.8.1",
"react": "18.2.0",
"react-dom": "18.2.0",
@ -23,6 +25,8 @@
},
"devDependencies": {
"@redwoodjs/vite": "6.3.2",
"@types/filestack-react": "^4.0.3",
"@types/node": "^20.8.9",
"@types/react": "18.2.14",
"@types/react-dom": "18.2.6",
"autoprefixer": "^10.4.16",

BIN
web/public/no_image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -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 = () => (

View File

@ -10,10 +10,17 @@
import { Router, Route, Set } from '@redwoodjs/router'
import NavbarLayout from 'src/layouts/NavbarLayout'
import ScaffoldLayout from 'src/layouts/ScaffoldLayout'
const Routes = () => {
return (
<Router>
<Set wrap={ScaffoldLayout} title="Parts" titleTo="parts" buttonLabel="New Part" buttonTo="newPart">
<Route path="/admin/parts/new" page={PartNewPartPage} name="newPart" />
<Route path="/admin/parts/{id:Int}/edit" page={PartEditPartPage} name="editPart" />
<Route path="/admin/parts/{id:Int}" page={PartPartPage} name="part" />
<Route path="/admin/parts" page={PartPartsPage} name="parts" />
</Set>
<Set wrap={NavbarLayout}>
<Route path="/" page={HomePage} name="home" />
</Set>

View File

@ -0,0 +1,68 @@
import type { EditPartById, UpdatePartInput } from 'types/graphql'
import { navigate, routes } from '@redwoodjs/router'
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import PartForm from 'src/components/Part/PartForm'
export const QUERY = gql`
query EditPartById($id: Int!) {
part: part(id: $id) {
id
name
description
availableStock
imageUrl
createdAt
}
}
`
const UPDATE_PART_MUTATION = gql`
mutation UpdatePartMutation($id: Int!, $input: UpdatePartInput!) {
updatePart(id: $id, input: $input) {
id
name
description
availableStock
imageUrl
createdAt
}
}
`
export const Loading = () => <div>Loading...</div>
export const Failure = ({ error }: CellFailureProps) => (
<div className="rw-cell-error">{error?.message}</div>
)
export const Success = ({ part }: CellSuccessProps<EditPartById>) => {
const [updatePart, { loading, error }] = useMutation(UPDATE_PART_MUTATION, {
onCompleted: () => {
toast.success('Part updated')
navigate(routes.parts())
},
onError: (error) => {
toast.error(error.message)
},
})
const onSave = (input: UpdatePartInput, id: EditPartById['part']['id']) => {
updatePart({ variables: { id, input } })
}
return (
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">
Edit Part {part?.id}
</h2>
</header>
<div className="rw-segment-main">
<PartForm part={part} onSave={onSave} error={error} loading={loading} />
</div>
</div>
)
}

View File

@ -0,0 +1,44 @@
import { navigate, routes } from '@redwoodjs/router'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import PartForm from 'src/components/Part/PartForm'
import type { CreatePartInput } from 'types/graphql'
const CREATE_PART_MUTATION = gql`
mutation CreatePartMutation($input: CreatePartInput!) {
createPart(input: $input) {
id
}
}
`
const NewPart = () => {
const [createPart, { loading, error }] = useMutation(CREATE_PART_MUTATION, {
onCompleted: () => {
toast.success('Part created')
navigate(routes.parts())
},
onError: (error) => {
toast.error(error.message)
},
})
const onSave = (input: CreatePartInput) => {
createPart({ variables: { input } })
}
return (
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">New Part</h2>
</header>
<div className="rw-segment-main">
<PartForm onSave={onSave} loading={loading} error={error} />
</div>
</div>
)
}
export default NewPart

View File

@ -0,0 +1,94 @@
import type { DeletePartMutationVariables, FindPartById } from 'types/graphql'
import { Link, routes, navigate } from '@redwoodjs/router'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { timeTag } from 'src/lib/formatters'
const DELETE_PART_MUTATION = gql`
mutation DeletePartMutation($id: Int!) {
deletePart(id: $id) {
id
}
}
`
interface Props {
part: NonNullable<FindPartById['part']>
}
const Part = ({ part }: Props) => {
const [deletePart] = useMutation(DELETE_PART_MUTATION, {
onCompleted: () => {
toast.success('Part deleted')
navigate(routes.parts())
},
onError: (error) => {
toast.error(error.message)
},
})
const onDeleteClick = (id: DeletePartMutationVariables['id']) => {
if (confirm('Are you sure you want to delete part ' + id + '?')) {
deletePart({ variables: { id } })
}
}
return (
<>
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">
Part {part.id} Detail
</h2>
</header>
<table className="rw-table">
<tbody>
<tr>
<th>Id</th>
<td>{part.id}</td>
</tr>
<tr>
<th>Name</th>
<td>{part.name}</td>
</tr>
<tr>
<th>Description</th>
<td>{part.description}</td>
</tr>
<tr>
<th>Available stock</th>
<td>{part.availableStock}</td>
</tr>
<tr>
<th>Image</th>
<td>{part.imageUrl}</td>
</tr>
<tr>
<th>Created at</th>
<td>{timeTag(part.createdAt)}</td>
</tr>
</tbody>
</table>
</div>
<nav className="rw-button-group">
<Link
to={routes.editPart({ id: part.id })}
className="rw-button rw-button-blue"
>
Edit
</Link>
<button
type="button"
className="rw-button rw-button-red"
onClick={() => onDeleteClick(part.id)}
>
Delete
</button>
</nav>
</>
)
}
export default Part

View File

@ -0,0 +1,30 @@
import type { FindPartById } from 'types/graphql'
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
import Part from 'src/components/Part/Part'
export const QUERY = gql`
query FindPartById($id: Int!) {
part: part(id: $id) {
id
name
description
availableStock
imageUrl
createdAt
}
}
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>Part not found</div>
export const Failure = ({ error }: CellFailureProps) => (
<div className="rw-cell-error">{error?.message}</div>
)
export const Success = ({ part }: CellSuccessProps<FindPartById>) => {
return <Part part={part} />
}

View File

@ -0,0 +1,153 @@
import { useState } from 'react'
import { PickerInline } from 'filestack-react'
import type { EditPartById, UpdatePartInput } from 'types/graphql'
import {
Form,
FormError,
FieldError,
Label,
TextField,
NumberField,
Submit,
} from '@redwoodjs/forms'
import type { RWGqlError } from '@redwoodjs/forms'
type FormPart = NonNullable<EditPartById['part']>
interface PartFormProps {
part?: EditPartById['part']
onSave: (data: UpdatePartInput, id?: FormPart['id']) => void
error: RWGqlError
loading: boolean
}
const PartForm = (props: PartFormProps) => {
const [imageUrl, setImageUrl] = useState(props?.part?.imageUrl)
const onSubmit = (data: FormPart) => {
const dataWithImageUrl = Object.assign(data, { imageUrl })
props.onSave(dataWithImageUrl, props?.part?.id)
}
const onImageUpload = (response) => {
setImageUrl(response.filesUploaded[0].url)
}
const preview = (url: string) => {
const parts = url.split('/')
parts.splice(3, 0, 'resize=height:500')
return parts.join('/')
}
return (
<div className="rw-form-wrapper">
<Form<FormPart> onSubmit={onSubmit} error={props.error}>
<FormError
error={props.error}
wrapperClassName="rw-form-error-wrapper"
titleClassName="rw-form-error-title"
listClassName="rw-form-error-list"
/>
<Label
name="name"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Name
</Label>
<TextField
name="name"
defaultValue={props.part?.name}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
/>
<FieldError name="name" className="rw-field-error" />
<Label
name="description"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Description
</Label>
<TextField
name="description"
defaultValue={props.part?.description}
className="rw-input"
errorClassName="rw-input rw-input-error"
/>
<FieldError name="description" className="rw-field-error" />
<Label
name="availableStock"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Available stock
</Label>
<NumberField
name="availableStock"
defaultValue={props.part?.availableStock}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
/>
<FieldError name="availableStock" className="rw-field-error" />
<Label
name="imageUrl"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Image
</Label>
{!imageUrl && (
<div style={{ height: '500px' }}>
<PickerInline
onSuccess={onImageUpload}
pickerOptions={{ accept: 'image/*' }}
apikey={process.env.REDWOOD_ENV_FILESTACK_API_KEY}
/>
</div>
)}
{imageUrl && (
<div>
<img
alt=""
src={preview(imageUrl)}
style={{ display: 'block', margin: '2rem 0' }}
/>
<button
onClick={() => setImageUrl(null)}
className="rw-button rw-button-blue"
>
Replace Image
</button>
</div>
)}
<FieldError name="imageUrl" className="rw-field-error" />
<div className="rw-button-group">
<Submit disabled={props.loading} className="rw-button rw-button-blue">
Save
</Submit>
</div>
</Form>
</div>
)
}
export default PartForm

View File

@ -0,0 +1,110 @@
import type { DeletePartMutationVariables, FindParts } from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { QUERY } from 'src/components/Part/PartsCell'
import { timeTag, truncate } from 'src/lib/formatters'
const DELETE_PART_MUTATION = gql`
mutation DeletePartMutation($id: Int!) {
deletePart(id: $id) {
id
}
}
`
const PartsList = ({ parts }: FindParts) => {
const [deletePart] = useMutation(DELETE_PART_MUTATION, {
onCompleted: () => {
toast.success('Part deleted')
},
onError: (error) => {
toast.error(error.message)
},
// This refetches the query on the list page. Read more about other ways to
// update the cache over here:
// https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates
refetchQueries: [{ query: QUERY }],
awaitRefetchQueries: true,
})
const onDeleteClick = (id: DeletePartMutationVariables['id']) => {
if (confirm('Are you sure you want to delete part ' + id + '?')) {
deletePart({ variables: { id } })
}
}
const thumbnail = (url: string) => {
const parts = url.split('/')
parts.splice(3, 0, 'resize=width:100')
return parts.join('/')
}
return (
<div className="rw-segment rw-table-wrapper-responsive">
<table className="rw-table">
<thead>
<tr>
<th>Id</th>
<th>Name</th>
<th>Description</th>
<th>Available stock</th>
<th>Image</th>
<th>Created at</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
{parts.map((part) => (
<tr key={part.id}>
<td>{truncate(part.id)}</td>
<td>{truncate(part.name)}</td>
<td>{truncate(part.description)}</td>
<td>{truncate(part.availableStock)}</td>
<td>
<a href={part.imageUrl} target="_blank" rel="noreferrer">
<img
alt={`${part.name} thumbnail`}
src={thumbnail(part.imageUrl)}
style={{ maxWidth: '50px' }}
/>
</a>
</td>
<td>{timeTag(part.createdAt)}</td>
<td>
<nav className="rw-table-actions">
<Link
to={routes.part({ id: part.id })}
title={'Show part ' + part.id + ' detail'}
className="rw-button rw-button-small"
>
Show
</Link>
<Link
to={routes.editPart({ id: part.id })}
title={'Edit part ' + part.id}
className="rw-button rw-button-small rw-button-blue"
>
Edit
</Link>
<button
type="button"
title={'Delete part ' + part.id}
className="rw-button rw-button-small rw-button-red"
onClick={() => onDeleteClick(part.id)}
>
Delete
</button>
</nav>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export default PartsList

View File

@ -0,0 +1,40 @@
import type { FindParts } from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
import Parts from 'src/components/Part/Parts'
export const QUERY = gql`
query FindParts {
parts {
id
name
description
availableStock
imageUrl
createdAt
}
}
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => {
return (
<div className="rw-text-center">
{'No parts yet. '}
<Link to={routes.newPart()} className="rw-link">
{'Create one?'}
</Link>
</div>
)
}
export const Failure = ({ error }: CellFailureProps) => (
<div className="rw-cell-error">{error?.message}</div>
)
export const Success = ({ parts }: CellSuccessProps<FindParts>) => {
return <Parts parts={parts} />
}

View File

@ -0,0 +1,37 @@
import { Link, routes } from '@redwoodjs/router'
import { Toaster } from '@redwoodjs/web/toast'
type LayoutProps = {
title: string
titleTo: string
buttonLabel: string
buttonTo: string
children: React.ReactNode
}
const ScaffoldLayout = ({
title,
titleTo,
buttonLabel,
buttonTo,
children,
}: LayoutProps) => {
return (
<div className="rw-scaffold">
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
<header className="rw-header">
<h1 className="rw-heading rw-heading-primary">
<Link to={routes[titleTo]()} className="rw-link">
{title}
</Link>
</h1>
<Link to={routes[buttonTo]()} className="rw-button rw-button-green">
<div className="rw-button-icon">+</div> {buttonLabel}
</Link>
</header>
<main className="rw-main">{children}</main>
</div>
)
}
export default ScaffoldLayout

View File

@ -0,0 +1,192 @@
import { render, waitFor, screen } from '@redwoodjs/testing/web'
import {
formatEnum,
jsonTruncate,
truncate,
timeTag,
jsonDisplay,
checkboxInputTag,
} from './formatters'
describe('formatEnum', () => {
it('handles nullish values', () => {
expect(formatEnum(null)).toEqual('')
expect(formatEnum('')).toEqual('')
expect(formatEnum(undefined)).toEqual('')
})
it('formats a list of values', () => {
expect(
formatEnum(['RED', 'ORANGE', 'YELLOW', 'GREEN', 'BLUE', 'VIOLET'])
).toEqual('Red, Orange, Yellow, Green, Blue, Violet')
})
it('formats a single value', () => {
expect(formatEnum('DARK_BLUE')).toEqual('Dark blue')
})
it('returns an empty string for values of the wrong type (for JS projects)', () => {
// @ts-expect-error - Testing JS scenario
expect(formatEnum(5)).toEqual('')
})
})
describe('truncate', () => {
it('truncates really long strings', () => {
expect(truncate('na '.repeat(1000) + 'batman').length).toBeLessThan(1000)
expect(truncate('na '.repeat(1000) + 'batman')).not.toMatch(/batman/)
})
it('does not modify short strings', () => {
expect(truncate('Short strinG')).toEqual('Short strinG')
})
it('adds ... to the end of truncated strings', () => {
expect(truncate('repeat'.repeat(1000))).toMatch(/\w\.\.\.$/)
})
it('accepts numbers', () => {
expect(truncate(123)).toEqual('123')
expect(truncate(0)).toEqual('0')
expect(truncate(0o000)).toEqual('0')
})
it('handles arguments of invalid type', () => {
// @ts-expect-error - Testing JS scenario
expect(truncate(false)).toEqual('false')
expect(truncate(undefined)).toEqual('')
expect(truncate(null)).toEqual('')
})
})
describe('jsonTruncate', () => {
it('truncates large json structures', () => {
expect(
jsonTruncate({
foo: 'foo',
bar: 'bar',
baz: 'baz',
kittens: 'kittens meow',
bazinga: 'Sheldon',
nested: {
foobar: 'I have no imagination',
two: 'Second nested item',
},
five: 5,
bool: false,
})
).toMatch(/.+\n.+\w\.\.\.$/s)
})
})
describe('timeTag', () => {
it('renders a date', async () => {
render(<div>{timeTag(new Date('1970-08-20').toUTCString())}</div>)
await waitFor(() => screen.getByText(/1970.*00:00:00/))
})
it('can take an empty input string', async () => {
expect(timeTag('')).toEqual('')
})
})
describe('jsonDisplay', () => {
it('produces the correct output', () => {
expect(
jsonDisplay({
title: 'TOML Example (but in JSON)',
database: {
data: [['delta', 'phi'], [3.14]],
enabled: true,
ports: [8000, 8001, 8002],
temp_targets: {
case: 72.0,
cpu: 79.5,
},
},
owner: {
dob: '1979-05-27T07:32:00-08:00',
name: 'Tom Preston-Werner',
},
servers: {
alpha: {
ip: '10.0.0.1',
role: 'frontend',
},
beta: {
ip: '10.0.0.2',
role: 'backend',
},
},
})
).toMatchInlineSnapshot(`
<pre>
<code>
{
"title": "TOML Example (but in JSON)",
"database": {
"data": [
[
"delta",
"phi"
],
[
3.14
]
],
"enabled": true,
"ports": [
8000,
8001,
8002
],
"temp_targets": {
"case": 72,
"cpu": 79.5
}
},
"owner": {
"dob": "1979-05-27T07:32:00-08:00",
"name": "Tom Preston-Werner"
},
"servers": {
"alpha": {
"ip": "10.0.0.1",
"role": "frontend"
},
"beta": {
"ip": "10.0.0.2",
"role": "backend"
}
}
}
</code>
</pre>
`)
})
})
describe('checkboxInputTag', () => {
it('can be checked', () => {
render(checkboxInputTag(true))
expect(screen.getByRole('checkbox')).toBeChecked()
})
it('can be unchecked', () => {
render(checkboxInputTag(false))
expect(screen.getByRole('checkbox')).not.toBeChecked()
})
it('is disabled when checked', () => {
render(checkboxInputTag(true))
expect(screen.getByRole('checkbox')).toBeDisabled()
})
it('is disabled when unchecked', () => {
render(checkboxInputTag(false))
expect(screen.getByRole('checkbox')).toBeDisabled()
})
})

View File

@ -0,0 +1,58 @@
import React from 'react'
import humanize from 'humanize-string'
const MAX_STRING_LENGTH = 150
export const formatEnum = (values: string | string[] | null | undefined) => {
let output = ''
if (Array.isArray(values)) {
const humanizedValues = values.map((value) => humanize(value))
output = humanizedValues.join(', ')
} else if (typeof values === 'string') {
output = humanize(values)
}
return output
}
export const jsonDisplay = (obj: unknown) => {
return (
<pre>
<code>{JSON.stringify(obj, null, 2)}</code>
</pre>
)
}
export const truncate = (value: string | number) => {
let output = value?.toString() ?? ''
if (output.length > MAX_STRING_LENGTH) {
output = output.substring(0, MAX_STRING_LENGTH) + '...'
}
return output
}
export const jsonTruncate = (obj: unknown) => {
return truncate(JSON.stringify(obj, null, 2))
}
export const timeTag = (dateTime?: string) => {
let output: string | JSX.Element = ''
if (dateTime) {
output = (
<time dateTime={dateTime} title={dateTime}>
{new Date(dateTime).toUTCString()}
</time>
)
}
return output
}
export const checkboxInputTag = (checked: boolean) => {
return <input type="checkbox" checked={checked} disabled />
}

View File

@ -0,0 +1,11 @@
import EditPartCell from 'src/components/Part/EditPartCell'
type PartPageProps = {
id: number
}
const EditPartPage = ({ id }: PartPageProps) => {
return <EditPartCell id={id} />
}
export default EditPartPage

View File

@ -0,0 +1,7 @@
import NewPart from 'src/components/Part/NewPart'
const NewPartPage = () => {
return <NewPart />
}
export default NewPartPage

View File

@ -0,0 +1,11 @@
import PartCell from 'src/components/Part/PartCell'
type PartPageProps = {
id: number
}
const PartPage = ({ id }: PartPageProps) => {
return <PartCell id={id} />
}
export default PartPage

View File

@ -0,0 +1,7 @@
import PartsCell from 'src/components/Part/PartsCell'
const PartsPage = () => {
return <PartsCell />
}
export default PartsPage

243
web/src/scaffold.css Normal file
View File

@ -0,0 +1,243 @@
.rw-scaffold {
@apply bg-white text-gray-600;
}
.rw-scaffold h1,
.rw-scaffold h2 {
@apply m-0;
}
.rw-scaffold a {
@apply bg-transparent;
}
.rw-scaffold ul {
@apply m-0 p-0;
}
.rw-scaffold input:-ms-input-placeholder {
@apply text-gray-500;
}
.rw-scaffold input::-ms-input-placeholder {
@apply text-gray-500;
}
.rw-scaffold input::placeholder {
@apply text-gray-500;
}
.rw-header {
@apply flex justify-between px-8 py-4;
}
.rw-main {
@apply mx-4 pb-4;
}
.rw-segment {
@apply w-full overflow-hidden rounded-lg border border-gray-200;
scrollbar-color: theme('colors.zinc.400') transparent;
}
.rw-segment::-webkit-scrollbar {
height: initial;
}
.rw-segment::-webkit-scrollbar-track {
@apply rounded-b-[10px] rounded-t-none border-0 border-t border-solid border-gray-200 bg-transparent p-[2px];
}
.rw-segment::-webkit-scrollbar-thumb {
@apply rounded-full border-[3px] border-solid border-transparent bg-zinc-400 bg-clip-content;
}
.rw-segment-header {
@apply bg-gray-200 px-4 py-3 text-gray-700;
}
.rw-segment-main {
@apply bg-gray-100 p-4;
}
.rw-link {
@apply text-blue-400 underline;
}
.rw-link:hover {
@apply text-blue-500;
}
.rw-forgot-link {
@apply mt-1 text-right text-xs text-gray-400 underline;
}
.rw-forgot-link:hover {
@apply text-blue-500;
}
.rw-heading {
@apply font-semibold;
}
.rw-heading.rw-heading-primary {
@apply text-xl;
}
.rw-heading.rw-heading-secondary {
@apply text-sm;
}
.rw-heading .rw-link {
@apply text-gray-600 no-underline;
}
.rw-heading .rw-link:hover {
@apply text-gray-900 underline;
}
.rw-cell-error {
@apply text-sm font-semibold;
}
.rw-form-wrapper {
@apply -mt-4 text-sm;
}
.rw-cell-error,
.rw-form-error-wrapper {
@apply my-4 rounded border border-red-100 bg-red-50 p-4 text-red-600;
}
.rw-form-error-title {
@apply m-0 font-semibold;
}
.rw-form-error-list {
@apply mt-2 list-inside list-disc;
}
.rw-button {
@apply flex cursor-pointer justify-center rounded border-0 bg-gray-200 px-4 py-1 text-xs font-semibold uppercase leading-loose tracking-wide text-gray-500 no-underline transition duration-100;
}
.rw-button:hover {
@apply bg-gray-500 text-white;
}
.rw-button.rw-button-small {
@apply rounded-sm px-2 py-1 text-xs;
}
.rw-button.rw-button-green {
@apply bg-green-500 text-white;
}
.rw-button.rw-button-green:hover {
@apply bg-green-700;
}
.rw-button.rw-button-blue {
@apply bg-blue-500 text-white;
}
.rw-button.rw-button-blue:hover {
@apply bg-blue-700;
}
.rw-button.rw-button-red {
@apply bg-red-500 text-white;
}
.rw-button.rw-button-red:hover {
@apply bg-red-700 text-white;
}
.rw-button-icon {
@apply mr-1 text-xl leading-5;
}
.rw-button-group {
@apply mx-2 my-3 flex justify-center;
}
.rw-button-group .rw-button {
@apply mx-1;
}
.rw-form-wrapper .rw-button-group {
@apply mt-8;
}
.rw-label {
@apply mt-6 block text-left font-semibold text-gray-600;
}
.rw-label.rw-label-error {
@apply text-red-600;
}
.rw-input {
@apply mt-2 block w-full rounded border border-gray-200 bg-white p-2 outline-none;
}
.rw-check-radio-items {
@apply flex justify-items-center;
}
.rw-check-radio-item-none {
@apply text-gray-600;
}
.rw-input[type='checkbox'],
.rw-input[type='radio'] {
@apply ml-0 mr-1 mt-1 inline w-4;
}
.rw-input:focus {
@apply border-gray-400;
}
.rw-input-error {
@apply border-red-600 text-red-600;
}
.rw-input-error:focus {
@apply border-red-600 outline-none;
box-shadow: 0 0 5px #c53030;
}
.rw-field-error {
@apply mt-1 block text-xs font-semibold uppercase text-red-600;
}
.rw-table-wrapper-responsive {
@apply overflow-x-auto;
}
.rw-table-wrapper-responsive .rw-table {
min-width: 48rem;
}
.rw-table {
@apply w-full text-sm;
}
.rw-table th,
.rw-table td {
@apply p-3;
}
.rw-table td {
@apply bg-white text-gray-900;
}
.rw-table tr:nth-child(odd) td,
.rw-table tr:nth-child(odd) th {
@apply bg-gray-50;
}
.rw-table thead tr {
@apply bg-gray-200 text-gray-600;
}
.rw-table th {
@apply text-left font-semibold;
}
.rw-table thead th {
@apply text-left;
}
.rw-table tbody th {
@apply text-right;
}
@media (min-width: 768px) {
.rw-table tbody th {
@apply w-1/5;
}
}
.rw-table tbody tr {
@apply border-t border-gray-200;
}
.rw-table input {
@apply ml-0;
}
.rw-table-actions {
@apply flex h-4 items-center justify-end pr-1;
}
.rw-table-actions .rw-button {
@apply bg-transparent;
}
.rw-table-actions .rw-button:hover {
@apply bg-gray-500 text-white;
}
.rw-table-actions .rw-button-blue {
@apply text-blue-500;
}
.rw-table-actions .rw-button-blue:hover {
@apply bg-blue-500 text-white;
}
.rw-table-actions .rw-button-red {
@apply text-red-600;
}
.rw-table-actions .rw-button-red:hover {
@apply bg-red-600 text-white;
}
.rw-text-center {
@apply text-center;
}
.rw-login-container {
@apply mx-auto my-16 flex w-96 flex-wrap items-center justify-center;
}
.rw-login-container .rw-form-wrapper {
@apply w-full text-center;
}
.rw-login-link {
@apply mt-4 w-full text-center text-sm text-gray-600;
}
.rw-webauthn-wrapper {
@apply mx-4 mt-6 leading-6;
}
.rw-webauthn-wrapper h2 {
@apply mb-4 text-xl font-bold;
}

View File

@ -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": [