diff --git a/packages/client/src/components/Link.tsx b/packages/client/src/components/Link.tsx index bc721b1..005a58a 100644 --- a/packages/client/src/components/Link.tsx +++ b/packages/client/src/components/Link.tsx @@ -7,6 +7,7 @@ import {withRouter} from 'react-router' interface ILinkProps extends IWithRouterProps> { + readonly className?: string readonly to: string } @@ -15,6 +16,7 @@ class ContextLink extends React.PureComponent { render() { const { + className, history, location, match, @@ -25,7 +27,7 @@ class ContextLink extends React.PureComponent { const href = this.urlFormatter.format(to, match.params) return ( - + {children} ) diff --git a/packages/client/src/crud/CRUD.test.tsx b/packages/client/src/crud/CRUD.test.tsx index 4edeb9e..0f78a10 100644 --- a/packages/client/src/crud/CRUD.test.tsx +++ b/packages/client/src/crud/CRUD.test.tsx @@ -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('TEST') + const crudReducer = new CRUDReducer('TEST', {name: ''}) const Crud = crudReducer.reduce const test = new TestUtils() @@ -100,7 +100,7 @@ describe('CRUD', () => { }) function dispatch( - method: TCRUDMethod, + method: TCRUDAsyncMethod, action: IPendingAction, ) { 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: {}, + }) + }) + }) + }) + }) diff --git a/packages/client/src/crud/CRUDActions.ts b/packages/client/src/crud/CRUDActions.ts index 30117ed..e4b1d3a 100644 --- a/packages/client/src/crud/CRUDActions.ts +++ b/packages/client/src/crud/CRUDActions.ts @@ -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 = TFilter, {method: Method, status: 'pending'}> @@ -144,6 +147,38 @@ export class FindManyActionCreator< } +export class FormActionCreator { + constructor(readonly actionType: ActionType) {} + + create = (): TCRUDCreateAction => { + return { + payload: undefined, + type: this.actionType, + method: 'create', + } + } + + edit = (params: {id: number}): TCRUDEditAction => { + return { + payload: {id: params.id}, + type: this.actionType, + method: 'edit', + } + } + + change = (params: { + id?: number, + key: keyof T, + value: string + }): TCRUDChangeAction => { + 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 + (actionType) + + return { + save, + update, + remove, + findOne, + findMany, + create, + edit, + change, + } } diff --git a/packages/client/src/crud/CRUDForm.tsx b/packages/client/src/crud/CRUDForm.tsx new file mode 100644 index 0000000..5d93bf8 --- /dev/null +++ b/packages/client/src/crud/CRUDForm.tsx @@ -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 { + onChange(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 { + errors: Partial> + 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(key: K, value: string): void +} + +export class CRUDField extends React.PureComponent> { + handleChange = (e: React.ChangeEvent) => { + const {onChange} = this.props + const {value} = e.target + onChange(this.props.name, value) + } + render() { + const {label, name, value, placeholder} = this.props + return ( + + + + + {!!this.props.Icon && ( + + + + )} + + + ) + } +} + +export class CRUDForm extends React.PureComponent> { + handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const {onSubmit, item} = this.props + onSubmit(item) + } + render() { + const {fields, item} = this.props + return ( +
+

{this.props.error}

+ {fields.map(field => { + const error = this.props.errors[field.name] + const value = item[field.name] + return ( + + key={field.name} + name={field.name} + label={field.label} + onChange={this.props.onChange} + error={error} + Icon={field.Icon} + value={String(value)} + type={field.type} + /> + ) + })} +
+ +
+ + ) + } +} diff --git a/packages/client/src/crud/CRUDList.tsx b/packages/client/src/crud/CRUDList.tsx index 82466fa..c19e712 100644 --- a/packages/client/src/crud/CRUDList.tsx +++ b/packages/client/src/crud/CRUDList.tsx @@ -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 { nameKey: keyof T diff --git a/packages/client/src/crud/CRUDReducer.ts b/packages/client/src/crud/CRUDReducer.ts index 218530f..205368c 100644 --- a/packages/client/src/crud/CRUDReducer.ts +++ b/packages/client/src/crud/CRUDReducer.ts @@ -15,7 +15,19 @@ export interface ICRUDMethodStatus { export interface ICRUDState { readonly ids: ReadonlyArray readonly byId: Record - status: ICRUDStatus + readonly status: ICRUDStatus + readonly form: ICRUDForm +} + +interface ICRUDForm { + readonly create: { + readonly item: Pick>, + readonly errors: Partial> + } + readonly byId: Record> + }> } export interface ICRUDStatus { @@ -32,12 +44,22 @@ export class CRUDReducer< > { readonly defaultState: ICRUDState - constructor(readonly actionName: ActionType) { + constructor( + readonly actionName: ActionType, + readonly newItem: Pick>, + ) { 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): ICRUDState => { + return { + ...state, + form: { + ...state.form, + create: { + item: this.newItem, + errors: {}, + }, + }, + } + } + + handleEdit = (state: ICRUDState, id: number): ICRUDState => { + return { + ...state, + form: { + ...state.form, + byId: { + ...state.form.byId, + [id]: { + item: state.byId[id], + errors: {}, + }, + }, + }, + } + } + + handleChange = (state: ICRUDState, payload: { + id?: number, + key: keyof T, + value: string, + }): ICRUDState => { + 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 | undefined, action: TCRUDAction, ): ICRUDState => { @@ -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) diff --git a/packages/client/src/crud/TCRUDAction.ts b/packages/client/src/crud/TCRUDAction.ts index d782c3d..4e96c0b 100644 --- a/packages/client/src/crud/TCRUDAction.ts +++ b/packages/client/src/crud/TCRUDAction.ts @@ -1,6 +1,8 @@ -import {TAsyncAction} from '../actions' +import {IAction, TAsyncAction} from '../actions' import {TCRUDMethod} from './TCRUDMethod' +// Async actions + export type TCRUDSaveAction = TAsyncAction & {method: Extract} @@ -16,9 +18,24 @@ export type TCRUDFindOneAction = export type TCRUDFindManyAction = TAsyncAction & {method: Extract} +// Synchronous actions + +export type TCRUDCreateAction = + IAction & {method: Extract} + +export type TCRUDEditAction = + IAction<{id: number}, ActionType> & {method: Extract} + +export type TCRUDChangeAction = + IAction<{id?: number, key: keyof T, value: string}, ActionType> + & {method: Extract} + export type TCRUDAction = TCRUDSaveAction | TCRUDUpdateAction | TCRUDRemoveAction | TCRUDFindOneAction | TCRUDFindManyAction + | TCRUDCreateAction + | TCRUDEditAction + | TCRUDChangeAction diff --git a/packages/client/src/crud/TCRUDMethod.ts b/packages/client/src/crud/TCRUDMethod.ts index 167e9c4..40f9af7 100644 --- a/packages/client/src/crud/TCRUDMethod.ts +++ b/packages/client/src/crud/TCRUDMethod.ts @@ -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