Add sync actions to CRUDActions

This commit is contained in:
Jerko Steiner 2019-04-03 11:41:54 +08:00
parent 71f7687ab9
commit 0b1cd203c3
8 changed files with 367 additions and 17 deletions

View File

@ -7,6 +7,7 @@ import {withRouter} from 'react-router'
interface ILinkProps
extends IWithRouterProps<Record<string, string>> {
readonly className?: string
readonly to: string
}
@ -15,6 +16,7 @@ class ContextLink extends React.PureComponent<ILinkProps> {
render() {
const {
className,
history,
location,
match,
@ -25,7 +27,7 @@ class ContextLink extends React.PureComponent<ILinkProps> {
const href = this.urlFormatter.format(to, match.params)
return (
<RouterLink to={href}>
<RouterLink className={className} to={href}>
{children}
</RouterLink>
)

View File

@ -1,7 +1,7 @@
import {createCRUDActions} from './CRUDActions'
import React from 'react'
import {AnyAction} from 'redux'
import {CRUDReducer, TCRUDMethod} from './'
import {CRUDReducer, TCRUDMethod, TCRUDAsyncMethod} from './'
import {HTTPClientMock, TestUtils, getError} from '../test-utils'
import {TMethod} from '@rondo/common'
import {IPendingAction} from '../actions'
@ -62,7 +62,7 @@ describe('CRUD', () => {
'/one/:oneId/two',
'TEST',
)
const crudReducer = new CRUDReducer<ITwo, 'TEST'>('TEST')
const crudReducer = new CRUDReducer<ITwo, 'TEST'>('TEST', {name: ''})
const Crud = crudReducer.reduce
const test = new TestUtils()
@ -100,7 +100,7 @@ describe('CRUD', () => {
})
function dispatch(
method: TCRUDMethod,
method: TCRUDAsyncMethod,
action: IPendingAction<unknown, string>,
) {
store.dispatch(action)
@ -109,13 +109,13 @@ describe('CRUD', () => {
return action
}
function getUrl(method: TCRUDMethod) {
function getUrl(method: TCRUDAsyncMethod) {
return method === 'save' || method === 'findMany'
? '/one/1/two'
: '/one/1/two/2'
}
function getHTTPMethod(method: TCRUDMethod): TMethod {
function getHTTPMethod(method: TCRUDAsyncMethod): TMethod {
switch (method) {
case 'save':
return 'post'
@ -131,7 +131,7 @@ describe('CRUD', () => {
describe('Promise rejections', () => {
const testCases: Array<{
method: TCRUDMethod
method: TCRUDAsyncMethod
params: any
}> = [{
method: 'findOne',
@ -203,7 +203,7 @@ describe('CRUD', () => {
const entity = {id: 100, name: 'test'}
const testCases: Array<{
method: TCRUDMethod
method: TCRUDAsyncMethod,
params: any
body?: any
response: any
@ -307,4 +307,68 @@ describe('CRUD', () => {
})
describe('synchronous methods', () => {
describe('create', () => {
it('resets form.create state', () => {
store.dispatch(actions.create())
expect(store.getState().Crud.form.create).toEqual({
item: {
name: '',
},
errors: {},
})
})
})
describe('change', () => {
it('sets value', () => {
store.dispatch(actions.change({key: 'name', value: 'test'}))
expect(store.getState().Crud.form.create).toEqual({
item: {
name: 'test',
},
errors: {},
})
})
})
describe('edit', () => {
beforeEach(() => {
http.mockAdd({
method: 'post',
data: {name: 'test'},
url: '/one/1/two',
}, {id: 100, name: 'test'})
})
afterEach(() => {
http.mockClear()
})
it('sets item as edited', async () => {
await store.dispatch(actions.save({
params: {oneId: 1},
body: {name: 'test'},
})).payload
store.dispatch(actions.edit({id: 100}))
expect(store.getState().Crud.form.byId[100]).toEqual({
item: {
id: 100,
name: 'test',
},
errors: {},
})
store.dispatch(actions.change({id: 100, key: 'name', value: 'grrr'}))
expect(store.getState().Crud.form.byId[100]).toEqual({
item: {
id: 100,
name: 'grrr',
},
errors: {},
})
})
})
})
})

View File

@ -1,7 +1,10 @@
import {TCRUDAction} from './TCRUDAction'
import {TCRUDMethod} from './TCRUDMethod'
import {IHTTPClient, ITypedRequestParams} from '../http'
import {IRoutes, TFilter, TOnlyDefined} from '@rondo/common'
import {TCRUDAction} from './TCRUDAction'
import {TCRUDChangeAction} from './TCRUDAction'
import {TCRUDCreateAction} from './TCRUDAction'
import {TCRUDEditAction} from './TCRUDAction'
import {TCRUDMethod} from './TCRUDMethod'
type TAction<T, ActionType extends string, Method extends TCRUDMethod> =
TFilter<TCRUDAction<T, ActionType>, {method: Method, status: 'pending'}>
@ -144,6 +147,38 @@ export class FindManyActionCreator<
}
export class FormActionCreator<T, ActionType extends string> {
constructor(readonly actionType: ActionType) {}
create = (): TCRUDCreateAction<ActionType> => {
return {
payload: undefined,
type: this.actionType,
method: 'create',
}
}
edit = (params: {id: number}): TCRUDEditAction<ActionType> => {
return {
payload: {id: params.id},
type: this.actionType,
method: 'edit',
}
}
change = (params: {
id?: number,
key: keyof T,
value: string
}): TCRUDChangeAction<T, ActionType> => {
return {
payload: params,
type: this.actionType,
method: 'change',
}
}
}
export function createCRUDActions<
T extends IRoutes,
EntityRoute extends keyof T & string,
@ -161,5 +196,17 @@ export function createCRUDActions<
const {findOne} = new FindOneActionCreator(http, entityRoute, actionType)
const {findMany} = new FindManyActionCreator(http, listRoute, actionType)
return {save, update, remove, findOne, findMany}
const {create, edit, change} = new FormActionCreator
<T[ListRoute]['post']['body'], ActionType>(actionType)
return {
save,
update,
remove,
findOne,
findMany,
create,
edit,
change,
}
}

View File

@ -0,0 +1,101 @@
import React from 'react'
import {Control, Field, Label, Icon, Input} from 'bloomer'
export type TCRUDFieldType = 'text' | 'password' | 'number' | 'email' | 'tel'
export interface ICRUDFieldProps<T> {
onChange<K extends keyof T>(key: K, value: string): void
Icon?: React.ComponentType
error?: string
label: string
placeholder?: string
name: keyof T & string
type: TCRUDFieldType
value: string
}
export interface ICRUDFormProps<T> {
errors: Partial<Record<keyof T & string, string>>
item: T
error: string
submitText: string
fields: Array<{
Icon?: React.ComponentType
label: string
placeholder?: string
name: keyof T & string
type: TCRUDFieldType
}>
onSubmit: (t: T) => void
onChange<K extends keyof T>(key: K, value: string): void
}
export class CRUDField<T> extends React.PureComponent<ICRUDFieldProps<T>> {
handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const {onChange} = this.props
const {value} = e.target
onChange(this.props.name, value)
}
render() {
const {label, name, value, placeholder} = this.props
return (
<Field>
<Label>{label}</Label>
<Control hasIcons={!!this.props.Icon}>
<Input
name={name}
onChange={this.handleChange}
placeholder={placeholder}
value={value}
/>
{!!this.props.Icon && (
<Icon isSize='small' isAlign='left'>
<this.props.Icon />
</Icon>
)}
</Control>
</Field>
)
}
}
export class CRUDForm<T> extends React.PureComponent<ICRUDFormProps<T>> {
handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const {onSubmit, item} = this.props
onSubmit(item)
}
render() {
const {fields, item} = this.props
return (
<form onSubmit={this.handleSubmit}>
<p className='error'>{this.props.error}</p>
{fields.map(field => {
const error = this.props.errors[field.name]
const value = item[field.name]
return (
<CRUDField<T>
key={field.name}
name={field.name}
label={field.label}
onChange={this.props.onChange}
error={error}
Icon={field.Icon}
value={String(value)}
type={field.type}
/>
)
})}
<div className='center'>
<input
className='button is-primary'
name='submit'
type='submit'
value={this.props.submitText}
/>
</div>
</form>
)
}
}

View File

@ -1,7 +1,7 @@
import React from 'react'
import {Button, Panel, PanelHeading, PanelBlock} from 'bloomer'
import {FaPlus, FaEdit, FaTimes} from 'react-icons/fa'
import {Link} from 'react-router-dom'
import {Link} from '../components'
export interface ICRUDListProps<T> {
nameKey: keyof T

View File

@ -15,7 +15,19 @@ export interface ICRUDMethodStatus {
export interface ICRUDState<T extends ICRUDEntity> {
readonly ids: ReadonlyArray<number>
readonly byId: Record<number, T>
status: ICRUDStatus
readonly status: ICRUDStatus
readonly form: ICRUDForm<T>
}
interface ICRUDForm<T extends ICRUDEntity> {
readonly create: {
readonly item: Pick<T, Exclude<keyof T, 'id'>>,
readonly errors: Partial<Record<keyof T, string>>
}
readonly byId: Record<number, {
readonly item: T,
readonly errors: Partial<Record<keyof T, string>>
}>
}
export interface ICRUDStatus {
@ -32,12 +44,22 @@ export class CRUDReducer<
> {
readonly defaultState: ICRUDState<T>
constructor(readonly actionName: ActionType) {
constructor(
readonly actionName: ActionType,
readonly newItem: Pick<T, Exclude<keyof T, 'id'>>,
) {
const defaultMethodStatus = this.getDefaultMethodStatus()
this.defaultState = {
ids: [],
byId: {},
form: {
byId: {},
create: {
item: newItem,
errors: {},
},
},
status: {
save: defaultMethodStatus,
@ -161,7 +183,77 @@ export class CRUDReducer<
}
}
reduce = (
handleCreate = (state: ICRUDState<T>): ICRUDState<T> => {
return {
...state,
form: {
...state.form,
create: {
item: this.newItem,
errors: {},
},
},
}
}
handleEdit = (state: ICRUDState<T>, id: number): ICRUDState<T> => {
return {
...state,
form: {
...state.form,
byId: {
...state.form.byId,
[id]: {
item: state.byId[id],
errors: {},
},
},
},
}
}
handleChange = (state: ICRUDState<T>, payload: {
id?: number,
key: keyof T,
value: string,
}): ICRUDState<T> => {
const {id, key, value} = payload
if (!id) {
return {
...state,
form: {
...state.form,
create: {
...state.form.create,
item: {
...state.form.create.item,
[key]: value,
},
},
},
}
}
return {
...state,
form: {
...state.form,
byId: {
...state.form.byId,
[id]: {
...state.form.byId[id],
item: {
...state.form.byId[id].item,
[key]: value,
},
},
},
},
}
}
reduce = (
state: ICRUDState<T> | undefined,
action: TCRUDAction<T, ActionType>,
): ICRUDState<T> => {
@ -172,6 +264,19 @@ export class CRUDReducer<
return state
}
if (!('status' in action)) {
switch (action.method) {
case 'change':
return this.handleChange(state, action.payload)
case 'edit':
return this.handleEdit(state, action.payload.id)
case 'create':
return this.handleCreate(state)
default:
return state
}
}
switch (action.status) {
case 'pending':
return this.handleLoading(state, action.method)

View File

@ -1,6 +1,8 @@
import {TAsyncAction} from '../actions'
import {IAction, TAsyncAction} from '../actions'
import {TCRUDMethod} from './TCRUDMethod'
// Async actions
export type TCRUDSaveAction<T, ActionType extends string> =
TAsyncAction<T, ActionType> & {method: Extract<TCRUDMethod, 'save'>}
@ -16,9 +18,24 @@ export type TCRUDFindOneAction<T, ActionType extends string> =
export type TCRUDFindManyAction<T, ActionType extends string> =
TAsyncAction<T[], ActionType> & {method: Extract<TCRUDMethod, 'findMany'>}
// Synchronous actions
export type TCRUDCreateAction<ActionType extends string> =
IAction<undefined, ActionType> & {method: Extract<TCRUDMethod, 'create'>}
export type TCRUDEditAction<ActionType extends string> =
IAction<{id: number}, ActionType> & {method: Extract<TCRUDMethod, 'edit'>}
export type TCRUDChangeAction<T, ActionType extends string> =
IAction<{id?: number, key: keyof T, value: string}, ActionType>
& {method: Extract<TCRUDMethod, 'change'>}
export type TCRUDAction<T, ActionType extends string> =
TCRUDSaveAction<T, ActionType>
| TCRUDUpdateAction<T, ActionType>
| TCRUDRemoveAction<T, ActionType>
| TCRUDFindOneAction<T, ActionType>
| TCRUDFindManyAction<T, ActionType>
| TCRUDCreateAction<ActionType>
| TCRUDEditAction<ActionType>
| TCRUDChangeAction<T, ActionType>

View File

@ -1 +1,15 @@
export type TCRUDMethod = 'save' | 'update' | 'findOne' | 'findMany' | 'remove'
export type TCRUDAsyncMethod =
'save'
| 'update'
| 'findOne'
| 'findMany'
| 'remove'
export type TCRUDSyncMethod =
| 'create'
| 'edit'
| 'change'
export type TCRUDMethod =
TCRUDAsyncMethod
| TCRUDSyncMethod