Improved number inputs and item edit form - no hookups

main
Ashelyn Dawn 5 years ago
parent b21ab94ac2
commit 44bca67092

@ -13,7 +13,13 @@ const upload = require('multer')({
})
router.get('/', async (req, res) => {
const items = await db.item.findAll()
const showUnpublished =
// Only respect query parameter if user is admin
(req.user && req.user.is_admin)
? req.query.showUnpublished
: false
const items = await db.item.findAll(showUnpublished)
res.json(items)
})
@ -44,11 +50,6 @@ router.get('/by-slug/:slug', async (req, res) => {
res.json(item)
})
router.delete('/:uuid', async (req, res) => {
const result = await db.item.removeItem(req.params.uuid)
res.json({deleted: true})
})
router.post('/:uuid/images', upload.single('image'), async (req, res) => {
// Handle either image upload body or JSON body
try {
@ -63,3 +64,13 @@ router.post('/:uuid/images', upload.single('image'), async (req, res) => {
res.json(await db.item.findById(req.params.uuid))
})
router.post('/:uuid/publish', async (req, res) => {
const item = await db.item.publish(req.params.uuid)
res.json(item)
})
router.post('/:uuid/unpublish', async (req, res) => {
const item = await db.item.unpublish(req.params.uuid)
res.json(item)
})

@ -0,0 +1,92 @@
import React, {useState, useRef} from 'react'
import useMeasure from 'use-measure'
import styles from './styles.module.css'
function truncateFixed(num, fixed) {
var re = new RegExp('^-?\\d+(?:\.\\d{0,' + (fixed || -1) + '})?');
return num.toString().match(re)[0];
}
export default function Input({label, prefix, numDecimals, error, hint, name, value, onChange, onBlur, isValid}){
const [currentDecimals, setCurrentDecimals] = useState((numDecimals === undefined || value === undefined || value === '') ? -1 : numDecimals)
const currentValue = value === undefined ? 0 : typeof value === 'number' ? value : typeof value === 'string' ? parseInt(value || '0', 10) : 0
const spanRef = useRef();
const {width} = useMeasure(spanRef);
const updateParent = value => onChange({target: {value}})
const onKeyDown = (ev) => {
const {key} = ev
switch(key) {
case 'Backspace':
setCurrentDecimals(dec => Math.max(dec - 1, -1))
if(currentDecimals > 0) {
updateParent(parseFloat(truncateFixed(currentValue, currentDecimals - 1)))
} else {
updateParent(Math.floor(currentValue / 10))
}
break;
case '.':
setCurrentDecimals(0)
break;
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
const digit = parseInt(key, 10)
// If we're still in the whole numbers
if(currentDecimals < 0) {
updateParent(currentValue * 10 + digit)
} else if (currentDecimals === 0) {
// Add decimal
setCurrentDecimals(dec => dec + 1)
updateParent(parseFloat(currentValue.toFixed(0) + '.' + digit))
} else if (currentDecimals < numDecimals) {
// Add to existing decimals
setCurrentDecimals(dec => dec + 1)
updateParent(parseFloat(currentValue.toFixed(currentDecimals) + digit))
} else {
// Can we play a tick noise of some sort?
}
break;
default:
return;
}
ev.preventDefault();
}
const filledText = ((prefix + ' ') || '')
+ (Math.floor(currentValue) || '0')
+ (currentDecimals >= 0 ? '.' : '')
+ (currentDecimals > 0 ? currentValue.toFixed(currentDecimals).split('.')[1] : '')
const remainingText =
(currentDecimals < 0 ? '.' : '')
+ (numDecimals !== undefined && currentDecimals < numDecimals ? '0'.repeat(numDecimals - Math.max(currentDecimals, 0)) : '')
return (
<div className={styles.formElementContainer}>
<label htmlFor={name}>{label}:</label>
<div className={styles.complexInput + ((isValid && !error)?'':' ' + styles.invalid)}>
<input style={{textIndent: width}} type="text" name={name} value="" onChange={()=>{}} onKeyDown={onKeyDown} onBlur={onBlur} />
<div className={styles.inputDecorators}>
<span ref={spanRef} className={styles.numFilled}>{filledText}</span>
<span className={styles.numRemaining}>{remainingText}</span>
</div>
</div>
{hint && <span className={styles.hint}>{error || (isValid ? '' : hint)}</span>}
</div>
)
}

@ -4,7 +4,8 @@ import axios from 'axios'
import styles from './styles.module.css'
export const Input = require('./input.js').default
export const NumInput = require('./numInput.js').default
export const IntegerInput = require('./integerInput.js').default
export const DecimalInput = require('./decimalInput.js').default
export const Button = require('./button.js').default
const errorReducer = (errors, action) => {
@ -84,11 +85,12 @@ export const FormController = function FormController({children, className, url
initialState.fields[child.props.name] = {
name: child.props.name,
validate: child.props.validate,
transform: child.props.transform,
value: child.props.initialValue || "",
isValid: child.props.validate
?(child.props.initialValue ? child.props.validate(child.props.initialValue): false)
:true,
touched: false
touched: child.props.initialValue !== undefined
}
})
@ -103,7 +105,10 @@ export const FormController = function FormController({children, className, url
const data = {}
for(const name in state.fields){
const field = state.fields[name]
data[field.name] = field.value
if(field.transform)
data[field.name] = field.transform(field.value)
else
data[field.name] = field.value
}
if(url)

@ -2,7 +2,9 @@ import React from 'react'
import styles from './styles.module.css'
export default function Input({label, error, hint, type, name, value, onChange, onBlur, isValid}){
export default function Input({_label, error, hint, type, name, value, onChange, onBlur, isValid}){
const label = _label || name.replace(name[0], name[0].toUpperCase())
return (
<div className={styles.formElementContainer}>
<label htmlFor={name}>{label}:</label>

@ -29,7 +29,8 @@
border: solid 1px rgba(0,0,0,.2);
border-radius: 2px;
transition-property: border-color,box-shadow;
transition: .2s ease-in-out;
transition-duration: .2s;
transition-timing-function: ease-in-out;
}
:global(.dark) .formElementContainer input, :global(.dark) .formElementContainer .complexInput {
@ -40,6 +41,31 @@
display: flex;
flex-direction: row;
overflow: hidden;
background: white;
position: relative;
}
.complexInput > .inputDecorators {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
pointer-events: none;
padding: 9px 10px;
}
.inputDecorators > span {
display: inline-block;
}
.inputDecorators > span {
color: black;
transition: .3s opacity;
}
.complexInput input:focus + .inputDecorators .numRemaining {
opacity: .6;
}
.complexInput > button {

@ -5,7 +5,7 @@ export default function Table({columns, rows, foot}) {
<table className={styles.table}>
<thead>
<tr>
{columns.map(column=>
{columns?.map(column=>
<th key={column.name}>{column.name}</th>
)}
</tr>

@ -7,8 +7,15 @@ const sharp = require('sharp')
const item = module.exports = {}
item.findAll = async () => {
const query = 'select * from sos.v_item'
item.findAll = async (showUnpublished = false) => {
let queryPublished = 'select * from sos.v_item where item_published = true'
let queryAll = 'select * from sos.v_item'
const query = (
showUnpublished === "true"
? queryAll
: queryPublished
)
debug(query);
@ -130,3 +137,27 @@ item.removeItem = async (item_uuid) => {
const {rows} = await pg.query(query)
return joinjs.map(rows, mappings, 'itemMap', 'item_')[0]
}
item.publish = async (item_uuid) => {
const query = {
text: 'select * from sos.publish_item($1)',
values: [
item_uuid
]
}
const {rows} = await pg.query(query)
return joinjs.map(rows, mappings, 'itemMap', 'item_')[0]
}
item.unpublish = async (item_uuid) => {
const query = {
text: 'select * from sos.unpublish_item($1)',
values: [
item_uuid
]
}
const {rows} = await pg.query(query)
return joinjs.map(rows, mappings, 'itemMap', 'item_')[0]
}

@ -6,7 +6,7 @@ create or replace view sos.v_item as
"image".image_mime_type,
"image".image_date_uploaded,
"user".user_email,
num_added - num_removed as item_number_in_stock
coalesce(num_added - num_removed, 0) as item_number_in_stock
from sos."item"
left join sos."image" on item.item_uuid = image.image_item_uuid
left join sos."user" on image.image_uploader_uuid = "user".user_uuid

@ -346,3 +346,27 @@ begin
-- Return cart
return query select * from sos.v_cart where cart_uuid = _cart_uuid;
end; $function$;
create or replace function sos.publish_item(_item_uuid uuid)
returns setof sos.v_item
language plpgsql
as $function$
begin
update sos."item"
set item_published = true
where item_uuid = _item_uuid;
return query select * from sos.v_item where item_uuid = _item_uuid;
end; $function$;
create or replace function sos.unpublish_item(_item_uuid uuid)
returns setof sos.v_item
language plpgsql
as $function$
begin
update sos."item"
set item_published = false
where item_uuid = _item_uuid;
return query select * from sos.v_item where item_uuid = _item_uuid;
end; $function$;

13
package-lock.json generated

@ -6290,6 +6290,11 @@
"resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
"integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc="
},
"resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"resolve": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz",
@ -7578,6 +7583,14 @@
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
},
"use-measure": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/use-measure/-/use-measure-0.3.0.tgz",
"integrity": "sha512-HohD5JGamEaNVN6dUM+pfVBUCblIVk8poTZgTTNTA+hd0TXHMazfBQS8SSb+L4ejlIg+CD3yzkPZDL4Y5A1LlA==",
"requires": {
"resize-observer-polyfill": "^1.5.1"
}
},
"use-subscription": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/use-subscription/-/use-subscription-1.1.1.tgz",

@ -32,6 +32,7 @@
"react": "^16.12.0",
"react-dom": "^16.12.0",
"sharp": "^0.24.1",
"use-measure": "^0.3.0",
"validator": "^12.2.0"
}
}

@ -3,5 +3,6 @@ import useRequireAdmin from '../../hooks/useRequireAdmin'
export default function AdminDashboard(){
useRequireAdmin()
// TODO: button for making a stockchange transaction
return <p>Admin Dashboard</p>
}

@ -0,0 +1,30 @@
import React from 'react'
import {FormController, Input, DecimalInput, Button} from '~/components/form'
EditItem.getInitialProps = async ({ctx: {axios, query: {slug}}}) => {
const {data: item} = await axios.get(`/api/items/by-slug/${slug}`)
return {item}
}
export default function EditItem({item}) {
const stringLengthAtLeastOne = str => str.length > 0
const slugRestrictions = str => {
if(str.length < 3) return false;
if(str.length > 20) return false;
if(!str.match(/^[-a-z0-9_]*$/i)) return false;
return true;
}
return (
<>
<h2>Editing {item.name}</h2>
<FormController afterSubmit={console.log}>
<Input name="name" initialValue={item.name} validate={stringLengthAtLeastOne} />
<Input name="description" initialValue={item.description} validate={stringLengthAtLeastOne} />
<Input label="URL Slug" name="urlslug" initialValue={item.urlslug} validate={slugRestrictions} />
<DecimalInput label="Price" name="price_cents" prefix="$" numDecimals={2} initialValue={item.price_cents / 100} transform={float => Math.floor(float * 100)} />
<Button type="submit">Save</Button>
</FormController>
</>
)
}

@ -0,0 +1,56 @@
import React, {useState} from 'react'
import Link from 'next/link'
import axios from 'axios'
import Table from '~/components/table'
Items.getInitialProps = async ({ctx}) => {
const {data: items} = await ctx.axios.get('/api/items?showUnpublished=true')
return {items}
}
export default function Items({items: _items}){
const [items, setItems] = useState(_items)
const updateItem = newItem => setItems(items=>items.map(i => {
if(i.uuid === newItem.uuid)
return newItem
return i
}))
const publish = item => async () => {
const {data: newItem} = await axios.post(`/api/items/${item.uuid}/publish`)
updateItem(newItem)
}
const unpublish = item => async () => {
const {data: newItem} = await axios.post(`/api/items/${item.uuid}/unpublish`)
updateItem(newItem)
}
return (
<>
<h2>Items</h2>
<Table
columns={[
{name: 'Name', extractor: item => item.name},
{name: 'URL', extractor: item =>
<Link href={`/store/item/${item.urlslug}`}><a>/store/item/{item.urlslug}</a></Link> },
{name: 'Price', extractor: item => `$${(item.price_cents / 100).toFixed(2)}`},
{name: 'Number in stock', extractor: item => item.number_in_stock},
{name: 'Actions', extractor: item => (
<span>
<Link href={`/admin/items/${item.urlslug}`}><a>Edit</a></Link>
{
item.published
?<button onClick={unpublish(item)} type="button" className="buttonLink">Unpublish</button>
:<button onClick={publish(item)} type="button" className="buttonLink">Publish</button>
}
</span>
)}
]}
rows={items}
/>
</>
)
}

@ -1,7 +1,7 @@
import React, {useState} from 'react'
import Head from 'next/head'
import Router from 'next/router'
import {FormController, NumInput, Button} from '~/components/form'
import {FormController, IntegerInput, Button} from '~/components/form'
import isNum from 'validator/lib/isNumeric'
import useCart from '../../../hooks/useCart'
@ -56,7 +56,7 @@ export default function Item({item}){
{ item.description.split('\n').map(p=>p.trim()).filter(p=>p !== '').map((p,i)=><p key={i}>{p}</p>) }
<FormController className={styles.form} url={`/api/cart/add/${item.uuid}`} afterSubmit={(cart)=>{setCart(cart); Router.push('/store/cart')}}>
<NumInput label="Add to cart" name="count" initialValue="1" minimum="1" validate={value=>isNum(value, {no_symbols: true})} />
<IntegerInput label="Add to cart" name="count" initialValue="1" minimum="1" validate={value=>isNum(value, {no_symbols: true})} />
{
item.number_in_stock > 0
? <Button type="submit">Add to cart</Button>

Loading…
Cancel
Save