Re-do editdata type: make all fields strings and optional for edit data

This commit is contained in:
arttu
2024-05-30 13:34:04 +03:00
parent 573a138edf
commit 94937fe9e2
16 changed files with 133 additions and 69 deletions
+3 -3
View File
@@ -9,7 +9,7 @@ import {
} from '../services/locality'
import { fixBigInt } from '../utils/common'
import { detailedDiff } from 'deep-object-diff'
import { LocalityDetailsType } from '../../../frontend/src/backendTypes'
import { EditDataType, LocalityDetailsType } from '../../../frontend/src/backendTypes'
const router = Router()
@@ -25,10 +25,10 @@ router.get('/:id', async (req, res) => {
res.status(200).send(fixBigInt(locality))
})
router.put('/', async (req: Request<object, object, { locality: LocalityDetailsType }>, res) => {
router.put('/', async (req: Request<object, object, { locality: EditDataType<LocalityDetailsType> }>, res) => {
const editedLocality = req.body.locality
const fixedEditedLocality = fixEditedLocality(editedLocality)
const oldLocality = await getLocalityDetails(fixedEditedLocality.lid)
const oldLocality = await getLocalityDetails(parseInt(fixedEditedLocality.lid!))
const difference = detailedDiff(oldLocality!, fixedEditedLocality)
const filteredLoc = filterLocality(difference.updated)
const validationErrors = validateEntireLocality(filteredLoc)
+4 -4
View File
@@ -1,6 +1,6 @@
import { logger } from '../utils/logger'
import { prisma } from '../utils/db'
import { LocalityDetailsType } from '../../../frontend/src/backendTypes'
import { EditDataType, LocalityDetailsType } from '../../../frontend/src/backendTypes'
import Prisma from '@prisma/client'
import { validateLocality } from '../../../frontend/src/validators/locality'
@@ -60,7 +60,7 @@ export const getLocalityDetails = async (id: number) => {
return { ...locality, museums: now_mus.map(museum => museum.com_mlist) }
}
export const fixEditedLocality = (editedLocality: LocalityDetailsType) => {
export const fixEditedLocality = (editedLocality: EditDataType<LocalityDetailsType>) => {
if (editedLocality.now_lau) {
editedLocality.now_lau = editedLocality.now_lau.map(lau => ({
...lau,
@@ -83,13 +83,13 @@ export const validateEntireLocality = (editedFields: Partial<Prisma.now_loc>) =>
const keys = Object.keys(editedFields)
const errors = []
for (const key of keys) {
const error = validateLocality(editedFields as LocalityDetailsType, key as keyof LocalityDetailsType)
const error = validateLocality(editedFields as EditDataType<LocalityDetailsType>, key as keyof LocalityDetailsType)
if (error !== null) errors.push(error)
}
return errors
}
export const filterLocality = (editedFields: Partial<LocalityDetailsType>) => {
export const filterLocality = (editedFields: EditDataType<LocalityDetailsType>) => {
const fields = Object.keys(prisma.now_loc.fields)
const filteredLoc = Object.entries(editedFields)
.filter(([field]) => fields.includes(field))
+17
View File
@@ -1,5 +1,22 @@
import * as Prisma from '../../backend/node_modules/@prisma/client/default'
type PartialString<T> = {
[P in keyof T]: T[P] extends string | null ? T[P] | undefined : T[P]
}
// Makes all number fields strings, and then all string fields optional! Used for editData
type EditFields<T> = PartialString<{
[P in keyof T]: T[P] extends number | bigint | null ? string : T[P]
}>
type EditDataType<T> = EditFields<{
[P in keyof T]: T[P] extends (infer U)[]
? EditDataType<U>[]
: T[P] extends object
? EditDataType<PartialString<EditFields<T[P]>>>
: T[P]
}>
export type RowState = 'new' | 'removed' | 'cancelled' | 'clean'
export type UpdateComment = { update_comment: string }
// Use this for fields that include array that has to be edited by EditableTable.
@@ -2,6 +2,8 @@
import { ReactNode, createContext, useState, JSX, useEffect, Context, useContext } from 'react'
import { DropdownOption } from '../common/editingComponents'
import { cloneDeep } from 'lodash-es'
import { ValidationObject } from '@/validators/validator'
import { EditDataType } from '@/backendTypes'
export type ModeOptions = 'read' | 'new' | 'edit' | 'staging-edit' | 'staging-new'
@@ -49,35 +51,54 @@ export type DetailContextType<T> = {
data: T
mode: ModeType
setMode: (newMode: ModeOptions) => void
editData: T
setEditData: (editData: T) => void
editData: EditDataType<T>
setEditData: (editData: EditDataType<T>) => void
textField: (field: keyof T, type?: React.HTMLInputTypeAttribute) => JSX.Element
bigTextField: (field: keyof T) => JSX.Element
dropdown: (field: keyof T, options: Array<DropdownOption | string>, name: string) => JSX.Element
dropdown: (field: keyof T, options: Array<DropdownOption | string>, name: string, disabled?: boolean) => JSX.Element
radioSelection: (field: keyof T, options: Array<DropdownOption | string>, name: string) => JSX.Element
validator: (editData: T, field: keyof T) => string | null
validator: (editData: EditDataType<T>, field: keyof T) => ValidationObject
}
export const DetailContext = createContext<DetailContextType<unknown>>(null!)
export const DetailContextProvider = <T extends object>({
/* This changes data so that all number or bigint fields become string */
export const makeEditData = <T,>(data: T): EditDataType<T> => {
const editData: EditDataType<T> = {} as EditDataType<T>
for (const field in data) {
if (Array.isArray(data[field])) {
editData[field] = (data[field] as never[]).map(item => makeEditData(item)) as never
} else if (typeof data[field] === 'object' && data[field] !== null) {
editData[field] = makeEditData(data[field]) as never
} else if (typeof data[field] === 'number') {
editData[field] = ('' + (data[field] as number)) as never
} else if (typeof data[field] === 'bigint') {
editData[field] = ('' + (data[field] as bigint)) as never
} else {
editData[field] = cloneDeep(data[field]) as never
}
}
return editData
}
export const DetailContextProvider = <T extends EditDataType<object>>({
children,
contextState,
}: {
children: ReactNode | ReactNode[]
contextState: Omit<DetailContextType<T>, 'setEditData'>
}) => {
const [editData, setEditData] = useState<T>(cloneDeep(contextState.data))
useEffect(() => setEditData(cloneDeep(contextState.data)), [contextState.data])
const [editData, setEditData] = useState<EditDataType<T>>(makeEditData(contextState.data))
useEffect(() => setEditData(makeEditData(contextState.data)), [contextState.data])
return (
<DetailContext.Provider
value={{
...contextState,
editData,
setEditData: (data: unknown) => setEditData(data as T),
validator: (editData: unknown, fieldName: keyof T) => contextState.validator(editData as T, fieldName),
setEditData: (data: unknown) => setEditData(data as EditDataType<T>),
validator: (editData: unknown, fieldName: keyof T) =>
contextState.validator(editData as EditDataType<T>, fieldName),
}}
>
{children}
@@ -2,12 +2,13 @@ import { useSearchParams } from 'react-router-dom'
import { Box, Button, Paper, Stack, Tab, Tabs } from '@mui/material'
import EditIcon from '@mui/icons-material/Edit'
import { useEffect, useState, JSX } from 'react'
import { DetailContextProvider, ModeOptions, ModeType, modeOptionToMode } from './Context/DetailContext'
import { cloneDeep } from 'lodash-es'
import { DetailContextProvider, ModeOptions, ModeType, makeEditData, modeOptionToMode } from './Context/DetailContext'
import { DropdownOption, DropdownSelector, EditableTextField, RadioSelector } from './common/editingComponents'
import { DetailBrowser } from './DetailBrowser'
import { StagingView } from './StagingView'
import { ReturnButton, WriteButton } from './components'
import { ValidationObject } from '@/validators/validator'
import { EditDataType } from '@/backendTypes'
export type TabType = {
title: string
@@ -22,8 +23,8 @@ export const DetailView = <T extends object>({
}: {
tabs: TabType[]
data: T
onWrite: (editData: T) => void
validator: (editData: T, field: keyof T) => string | null
onWrite: (editData: EditDataType<T>) => void
validator: (editData: EditDataType<T>, field: keyof EditDataType<T>) => ValidationObject
}) => {
const [searchParams, setSearchParams] = useSearchParams()
const getUrl = () => {
@@ -67,7 +68,7 @@ export const DetailView = <T extends object>({
data,
mode,
setMode,
editData: cloneDeep(data),
editData: makeEditData(data),
textField,
dropdown,
radioSelection,
@@ -20,7 +20,7 @@ export const EditableTable = <T extends Editable<MRT_RowData>, ParentType extend
field,
}: {
tableData?: Array<T> | null
editTableData?: Array<T> | null
editTableData?: Array<Editable<T>> | null
columns: MRT_ColumnDef<T>[]
editable?: boolean
field: keyof ParentType
@@ -34,7 +34,7 @@ export const EditableTable = <T extends Editable<MRT_RowData>, ParentType extend
// TODO: Using static index - need to use some id, probably sorting breaks this
const rowClicked = (index: number | undefined) => {
if (index === undefined) return
const items = [...editData[field]]
const items = [...(editData[field] as Array<Editable<object>>)]
if (items[index].rowState === 'new') {
items.splice(index, 1)
setEditData({ ...editData, [field]: items })
@@ -43,7 +43,7 @@ export const DropdownSelector = <T extends object>({
id={`${name}-multiselect`}
value={(editData[field] || '') as string}
onChange={(event: SelectChangeEvent) => setEditData({ ...editData, [field]: event.target.value })}
sx={{ width: fieldWidth }}
sx={{ width: fieldWidth, backgroundColor: disabled ? 'grey' : '' }}
size="small"
disabled={disabled}
>
@@ -150,10 +150,10 @@ export const EditableTextField = <T extends object>({
disabled?: boolean
}) => {
const { setEditData, editData, validator } = useDetailContext<T>()
const error = validator(editData, field)
const { error } = validator(editData, field)
const editingComponent = (
<TextField
sx={{ width: fieldWidth }}
sx={{ width: fieldWidth, backgroundColor: disabled ? 'grey' : '' }}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setEditData({ ...editData, [field]: event?.currentTarget?.value })
}
@@ -214,6 +214,7 @@ export const FieldWithTableSelection = <T extends object, ParentType extends obj
value={editData[targetField]}
onClick={() => setOpen(true)}
disabled={disabled}
sx={{ backgroundColor: disabled ? 'grey' : '' }}
/>
)
return <DataValue<ParentType> field={targetField} EditElement={editingComponent} />
@@ -4,8 +4,9 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import SaveIcon from '@mui/icons-material/Save'
import { usePageContext } from '../Page'
import { useDetailContext } from './Context/DetailContext'
import { EditDataType } from '@/backendTypes'
export const WriteButton = <T,>({ onWrite, text }: { onWrite: (editData: T) => void; text?: string }) => {
export const WriteButton = <T,>({ onWrite, text }: { onWrite: (editData: EditDataType<T>) => void; text?: string }) => {
const { editData, mode, setMode } = useDetailContext<T>()
return (
<Button
@@ -12,7 +12,7 @@ import { ProjectTab } from './Tabs/ProjectTab'
import { SpeciesTab } from './Tabs/SpeciesTab'
import { TaphonomyTab } from './Tabs/TaphonomyTab'
import { UpdateTab } from './Tabs/UpdateTab'
import { LocalityDetailsType } from '@/backendTypes'
import { EditDataType, LocalityDetailsType } from '@/backendTypes'
import { validateLocality } from '@/validators/locality'
export const LocalityDetails = () => {
@@ -22,7 +22,7 @@ export const LocalityDetails = () => {
if (isError) return <div>Error loading data</div>
if (isLoading || !data) return <CircularProgress />
const onWrite = (editData: LocalityDetailsType) => {
const onWrite = (editData: EditDataType<LocalityDetailsType>) => {
void editLocalityRequest(editData)
}
@@ -5,7 +5,7 @@ import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
import { TimeUnitTable } from '@/components/TimeUnit/TimeUnitTable'
export const AgeTab = () => {
const { textField, radioSelection, dropdown, bigTextField } = useDetailContext<LocalityDetailsType>()
const { textField, radioSelection, dropdown, bigTextField, editData } = useDetailContext<LocalityDetailsType>()
const fracOptions = [
{ value: '', display: 'None' },
'Early half 1:2',
@@ -54,26 +54,28 @@ export const AgeTab = () => {
[
'Minimum age',
textField('min_age', 'number'),
dropdown('bfa_min_abs', bfa_abs_options, 'Min Basis for age (absolute)'),
dropdown('bfa_min_abs', bfa_abs_options, 'Min Basis for age (absolute)', editData.date_meth === 'time_unit'),
<FieldWithTableSelection<TimeUnitDetailsType, LocalityDetailsType>
key="bfa_min"
sourceField="tu_name"
targetField="bfa_min"
selectorTable={<TimeUnitTable />}
disabled={editData.date_meth === 'absolute'}
/>,
dropdown('frac_min', fracOptions, 'Minimum fraction'),
dropdown('frac_min', fracOptions, 'Minimum fraction', editData.date_meth === 'absolute'),
],
[
'Maximum age',
textField('max_age'),
dropdown('bfa_max_abs', bfa_abs_options, 'Max Basis for age (absolute)'),
textField('max_age', 'number'),
dropdown('bfa_max_abs', bfa_abs_options, 'Max Basis for age (absolute)', editData.date_meth === 'time_unit'),
<FieldWithTableSelection<TimeUnitDetailsType, LocalityDetailsType>
key="bfa_max"
sourceField="tu_name"
targetField="bfa_max"
selectorTable={<TimeUnitTable />}
disabled={editData.date_meth === 'absolute'}
/>,
dropdown('frac_max', fracOptions, 'Maximum fraction'),
dropdown('frac_max', fracOptions, 'Maximum fraction', editData.date_meth === 'absolute'),
],
[''],
['Chronostrathigraphic age', textField('chron')],
@@ -194,10 +194,10 @@ export const LithologyTab = () => {
editingAction={(newSed: SedimentaryStructureValues) => {
setEditData({
...editData,
now_ss: [...editData.now_ss, { lid: editData.lid, sed_struct: newSed.ss_value, rowState: 'new' }],
now_ss: [...editData.now_ss, { lid: editData.lid!, sed_struct: newSed.ss_value, rowState: 'new' }],
})
}}
selectedValues={editData.now_ss.map(ss => ss.sed_struct)}
selectedValues={editData.now_ss.map(ss => ss.sed_struct!)}
/>
</>
)}
@@ -58,5 +58,15 @@ export const SpeciesDetails = () => {
},
]
return <DetailView tabs={tabs} data={data} onWrite={() => {}} validator={() => null} />
return (
<DetailView
tabs={tabs}
data={data}
onWrite={() => {}}
validator={() => ({
name: 'unknown',
error: null,
})}
/>
)
}
@@ -31,7 +31,7 @@ export const LocalityTab = () => {
header: 'Min Age',
},
]
// TODO fix
// Here is code for adding new locality from Species > Localities tab.
// Let's add it later, as it is quite complicated to handle.
@@ -67,8 +67,11 @@ export const LocalityTab = () => {
)}
<EditableTable<Editable<Locality>, SpeciesDetailsType>
columns={columns}
editTableData={editData.now_ls.map(item =>
!item.now_loc ? (item as unknown as Editable<Locality>) : item.now_loc
editTableData={editData.now_ls.map(
item =>
!item.now_loc
? (item as unknown as Editable<Locality>)
: ({ ...item.now_loc, lid: item.now_loc.lid! } as never) // TODO fix type
)}
tableData={data.now_ls.map(item => (!item.now_loc ? (item as unknown as Editable<Locality>) : item.now_loc))}
editable
+2 -2
View File
@@ -1,5 +1,5 @@
import { api } from './api'
import { Locality, LocalityDetailsType } from '@/backendTypes'
import { EditDataType, Locality, LocalityDetailsType } from '@/backendTypes'
const localitiesApi = api.injectEndpoints({
endpoints: builder => ({
@@ -14,7 +14,7 @@ const localitiesApi = api.injectEndpoints({
}),
providesTags: result => (result ? [{ type: 'locality', id: result.lid }] : []),
}),
editLocality: builder.mutation<LocalityDetailsType, LocalityDetailsType>({
editLocality: builder.mutation<LocalityDetailsType, EditDataType<LocalityDetailsType>>({
query: locality => ({
url: `/locality/`,
method: 'PUT',
+17 -14
View File
@@ -1,22 +1,24 @@
import { LocalityDetailsType } from '../backendTypes'
import { EditDataType, LocalityDetailsType } from '../backendTypes'
import { Validators, validator } from './validator'
export const validateLocality = (editData: LocalityDetailsType, fieldName: keyof LocalityDetailsType) => {
const validators: Validators<Partial<LocalityDetailsType>> = {
bfa_max_abs: {
name: 'Basis for age (absolute)',
export const validateLocality = (editData: EditDataType<LocalityDetailsType>, fieldName: keyof LocalityDetailsType) => {
const validators: Validators<Partial<EditDataType<LocalityDetailsType>>> = {
max_age: {
name: 'Age (max)',
asNumber: (num: number) => {
if (num < parseInt(editData.bfa_min_abs ?? '0')) return 'Max value cannot be lower than min'
return
const min_age = editData.min_age
if (!min_age) return
if (num < parseInt(min_age)) return 'Max value cannot be lower than min'
return null
},
},
bfa_min_abs: {
name: 'Basis for age (minimum)',
min_age: {
name: 'Age (min)',
asNumber: (num: number) => {
const bfa_max_abs = editData.bfa_max_abs
if (!bfa_max_abs) return
if (parseInt(bfa_max_abs) > num) return 'Min value cannot be higher than max'
return
const max_age = editData.max_age
if (!max_age) return
if (num > parseInt(max_age)) return 'Min value cannot be higher than max'
return null
},
},
loc_name: {
@@ -27,5 +29,6 @@ export const validateLocality = (editData: LocalityDetailsType, fieldName: keyof
},
}
return validator(validators, editData, fieldName)
const returnval = validator<LocalityDetailsType>(validators, editData, fieldName)
return returnval
}
+16 -11
View File
@@ -1,26 +1,28 @@
import { EditDataType } from '../backendTypes'
const isValidNumber = (value: string) => !Number.isNaN(parseInt(value))
type ValidationReturn = string | null | undefined
export type ValidationError = string | null | undefined
export type ValidationObject = { name: string; error: ValidationError }
type Validator = {
name: string
required?: boolean
minLength?: number
maxLength?: number
asNumber?: ((num: number) => ValidationReturn) | boolean
asString?: ((str: string) => ValidationReturn) | boolean
asNumber?: ((num: number) => ValidationError) | boolean
asString?: ((str: string) => ValidationError) | boolean
}
export type Validators<T> = { [field in keyof T]: Validator }
const validate: (validator: Validator, value: unknown) => ValidationReturn = (validator: Validator, value: unknown) => {
const validate: (validator: Validator, value: unknown) => ValidationError = (validator: Validator, value: unknown) => {
const { required, minLength, maxLength, asNumber, asString } = validator
if (value === null || value === undefined) return required ? 'This field is required' : null
if (asNumber) {
if (typeof value !== 'string') return 'Error in type' // TODO: How to do this...
if (typeof value !== 'string') return 'Error in type' // Shouldn't happen due to editdata being all string
if (!isValidNumber(value)) return 'Value must be a valid number'
if (typeof asNumber === 'function') return asNumber(parseInt(value))
return isValidNumber(value) ? 'Value must be a valid number' : null
return null
}
if (asString) {
if (typeof value !== 'string') return 'Value must be of type string'
@@ -32,10 +34,13 @@ const validate: (validator: Validator, value: unknown) => ValidationReturn = (va
return null
}
export const validator = <T>(validators: Validators<T>, editData: T, fieldName: keyof T) => {
export const validator = <T>(
validators: Validators<Partial<T>>,
editData: EditDataType<T>,
fieldName: keyof T
): ValidationObject => {
const validator = validators[fieldName]
if (validator === undefined) return null
if (validator === undefined) return { name: fieldName as string, error: null }
const validationError = validate(validator, editData[fieldName])
if (!validationError) return null
return `Field validation failed for ${validator.name}: ${validationError}`
return { name: validator.name, error: validationError }
}