fix: admin/options: unusable mime editor
Massimo Melina committed
Aug 7, 2023 at 00:01 UTC
100c1fd5e802671889ae1df31f81eafe0030fa0d
11 files changed
+153
-194
admin/src/ArrayField.ts
+88
-62
@@ -1,81 +1,28 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { createElement as h, Fragment, useMemo } from 'react'
4
-import { IconBtn, setHidden } from './misc'
5
-import { Add, Edit, Delete } from '@mui/icons-material'
3
+import { createElement as h, Fragment, useMemo, useState } from 'react'
4
+import { IconBtn, isOrderedEqual, setHidden, swap } from './misc'
5
+import { Add, Edit, Delete, ArrowUpward, ArrowDownward, Undo } from '@mui/icons-material'
6
import { formDialog } from './dialog'
7
-import { DataGrid, GridAlignment } from '@mui/x-data-grid'
7
+import { DataGrid, GridActionsCellItem, GridAlignment } from '@mui/x-data-grid'
8
import { FieldDescriptor, FieldProps, labelFromKey } from '@hfs/mui-grid-form'
9
import { Box, FormHelperText, FormLabel } from '@mui/material'
10
11
-type ArrayFieldProps<T> = FieldProps<T[]> & { fields: FieldDescriptor[], height?: number }
12
-export function ArrayField<T extends object>({ label, helperText, fields, value, onChange, onError, getApi, ...rest }: ArrayFieldProps<T>) {
11
+type ArrayFieldProps<T> = FieldProps<T[]> & { fields: FieldDescriptor[], height?: number, reorder?: boolean, prepend?: boolean }
12
+export function ArrayField<T extends object>({ label, helperText, fields, value, onChange, onError, setApi, reorder, prepend, ...rest }: ArrayFieldProps<T>) {
13
const rows = useMemo(() => (value||[]).map((x,$idx) =>
14
setHidden({ ...x } as any, x.hasOwnProperty('id') ? { $idx } : { id: $idx })),
15
[JSON.stringify(value)]) //eslint-disable-line
16
const form = {
17
fields: fields.map(({ $width, $column, ...rest }) => rest)
18
}
19
- const columns = useMemo(() => {
20
- return [
21
- ...fields.map(f => ({
22
- field: f.k,
23
- headerName: f.headerName ?? (typeof f.label === 'string' ? f.label : labelFromKey(f.k)),
24
- disableColumnMenu: true,
25
- ...f.$width >= 8 ? { width: f.$width } : { flex: f.$width || 1 },
26
- ...f.$column,
27
- })),
28
- {
29
- field: '',
30
- width: 80,
31
- disableColumnMenu: true,
32
- sortable: false,
33
- align: 'center' as GridAlignment,
34
- headerAlign: 'center' as GridAlignment,
35
- renderHeader(){
36
- const title = "Add"
37
- return h(IconBtn, {
38
- icon: Add,
39
- title,
40
- onClick: (event:any) =>
41
- formDialog({ form, title }).then(o => // @ts-ignore
42
- o && onChange([...value||[], o], { was: value, event }))
43
- })
44
- },
45
- renderCell({ row }: any) {
46
- const { $idx=row.id } = row
47
- const title = "Modify"
48
- return h('div', {},
49
- h(IconBtn, {
50
- icon: Edit,
51
- title,
52
- onClick: (event:any) =>
53
- formDialog<T>({ values: row, form, title }).then(newRec => {
54
- if (!newRec) return
55
- const newValue = value!.map((oldRec, i) => i === $idx ? newRec : oldRec)
56
- onChange(newValue, { was: value, event })
57
- }),
58
- }),
59
- h(IconBtn, {
60
- icon: Delete,
61
- title: "Delete",
62
- confirm: "Delete?",
63
- onClick(event: any) {
64
- const newValue = value!.filter((rec, i) => i !== $idx)
65
- onChange(newValue, { was: value, event })
66
- },
67
- }),
68
- )
69
- }
70
- }
71
- ]
72
- }, [fields, value, onChange])
19
+ setApi?.({ isEqual: isOrderedEqual }) // don't rely on stringify, as it wouldn't work with non-json values
20
+ const [undo, setUndo] = useState<typeof value>()
21
return h(Fragment, {},
22
label && h(FormLabel, { sx: { ml: 1 } }, label),
23
helperText && h(FormHelperText, {}, helperText),
76
- h(Box, { height: '20em', ...rest },
24
+ h(Box, { ...rest },
25
h(DataGrid, {
78
- columns,
26
rows,
27
hideFooterSelectedRowCount: true,
28
hideFooter: true,
@@ -85,7 +32,86 @@ export function ArrayField<T extends object>({ label, helperText, fields, value,
32
showLastButton: true,
33
}
34
},
35
+ columns: [
36
+ ...fields.map(f => ({
37
+ field: f.k,
38
+ headerName: f.headerName ?? (typeof f.label === 'string' ? f.label : labelFromKey(f.k)),
39
+ disableColumnMenu: true,
40
+ ...f.$width >= 8 ? { width: f.$width } : { flex: f.$width || 1 },
41
+ ...f.$column,
42
+ })),
43
+ {
44
+ field: '',
45
+ type: 'actions',
46
+ width: 90,
47
+ headerAlign: 'center' as GridAlignment,
48
+ renderHeader(){
49
+ const title = "Add"
50
+ return h(Fragment, {},
51
+ h(IconBtn, {
52
+ icon: Add,
53
+ title,
54
+ size: 'small',
55
+ onClick: ev =>
56
+ formDialog<T>({ form, title }).then(x => {
57
+ if (!x) return
58
+ const newValue = value?.slice() || []
59
+ if (prepend) newValue.unshift(x)
60
+ else newValue.push(x)
61
+ set(newValue, ev)
62
+ })
63
+ }),
64
+ undo !== undefined && h(IconBtn, {
65
+ icon: Undo,
66
+ title: "Undo",
67
+ size: 'small',
68
+ onClick: ev => set(undo!, ev)
69
+ }),
70
+ )
71
+ },
72
+ getActions({ row }) {
73
+ const { $idx=row.id } = row
74
+ const title = "Modify"
75
+ return [
76
+ h(GridActionsCellItem as any, {
77
+ icon: h(Edit),
78
+ label: title,
79
+ onClick(event: MouseEvent) {
80
+ formDialog<T>({ values: row as any, form, title }).then(x => {
81
+ if (x)
82
+ set(value!.map((oldRec, i) => i === $idx ? x : oldRec), event)
83
+ })
84
+ }
85
+ }),
86
+ h(GridActionsCellItem as any, {
87
+ icon: h(Delete),
88
+ label: "Delete",
89
+ showInMenu: reorder,
90
+ onClick: ev => set(value!.filter((rec, i) => i !== $idx), ev),
91
+ }),
92
+ reorder && $idx && h(GridActionsCellItem as any, {
93
+ icon: h(ArrowUpward),
94
+ label: "Move up",
95
+ showInMenu: true,
96
+ onClick: ev => set(swap(value!.slice(), $idx, $idx - 1), ev),
97
+ }),
98
+ reorder && $idx < rows.length - 1 && h(GridActionsCellItem as any, {
99
+ icon: h(ArrowDownward),
100
+ label: "Move down",
101
+ showInMenu: true,
102
+ onClick: ev => set(swap(value!.slice(), $idx, $idx + 1), ev),
103
+ }),
104
+ ].filter(Boolean)
105
+ }
106
+ }
107
+ ]
108
})
109
)
110
)
111
+
112
+ function set(newValue: NonNullable<typeof value>, event?: any) {
113
+ onChange(newValue, { was: value, event })
114
+ setUndo(value)
115
+ }
116
+
117
}
admin/src/FileForm.ts
+2
-2
@@ -173,7 +173,7 @@ interface WhoFieldProps extends FieldProps<Who | undefined> {
173
contentText?: string
174
}
175
function WhoField({ value, onChange, parent, inherit, accounts, helperText, showInherited, otherPerms, byMasks,
176
- isChildren, isDir, contentText="folder content", ...rest }: WhoFieldProps): ReactElement {
176
+ isChildren, isDir, contentText="folder content", setApi, ...rest }: WhoFieldProps): ReactElement {
177
const defaultLabel = (byMasks !== undefined ? "As per mask: " : parent !== undefined ? "As parent: " : "Default: " )
178
+ who2desc(byMasks ?? inherit)
179
const objectMode = value != null && typeof value === 'object' && !Array.isArray(value)
@@ -197,7 +197,7 @@ function WhoField({ value, onChange, parent, inherit, accounts, helperText, show
197
const arrayMode = Array.isArray(thisValue)
198
// a large side band will convey union across the fields
199
return h(Box, { sx: { borderRight: objectMode ? '8px solid #8884' : undefined, transition: `all ${timeout}ms` } },
200
- h(SelectField as Field<typeof thisValue>, {
200
+ h(SelectField as typeof SelectField<typeof thisValue>, {
201
...rest,
202
value: arrayMode ? [] : thisValue,
203
onChange(v, { event }) {
admin/src/MonitorPage.ts
+1
-1
@@ -180,7 +180,7 @@ function Connections() {
180
hideUnder: Infinity,
181
renderCell: ({ value, row }) => h(Fragment, {},
182
"IPv" + value,
183
- row.secure && iconTooltip(Lock, "HTTPS", { opacity: .5 })
183
+ iconTooltip(Lock, "HTTPS", { opacity: .5 })
184
)
185
},
186
{
admin/src/OptionsPage.ts
+20
-16
@@ -5,22 +5,15 @@ import { createElement as h, Fragment, useEffect, useRef } from 'react';
5
import { apiCall, useApi, useApiEx } from './api'
6
import { state, useSnapState } from './state'
7
import { Info, Refresh, Warning } from '@mui/icons-material'
8
-import { Dict, Flex, modifiedSx, wikiLink, with_ } from './misc'
9
-import {
10
- Form,
11
- BoolField,
12
- NumberField,
13
- SelectField,
14
- StringStringField,
15
- FieldProps,
16
- Field,
17
- StringField
18
-} from '@hfs/mui-grid-form';
8
+import { Dict, Flex, iconTooltip, modifiedSx, wikiLink, with_ } from './misc'
9
+import { Form, BoolField, NumberField, SelectField, FieldProps, Field, StringField } from '@hfs/mui-grid-form';
10
+import { ArrayField } from './ArrayField'
11
import FileField from './FileField'
12
import { alertDialog, closeDialog, confirmDialog, newDialog, toast } from './dialog'
13
import { proxyWarning } from './HomePage'
14
import _ from 'lodash';
15
import { proxy, subscribe, useSnapshot } from 'valtio'
16
+import md from './md'
17
18
let loaded: Dict | undefined
19
let exposedReloadStatus: undefined | (() => void)
@@ -154,9 +147,20 @@ export default function OptionsPage() {
147
{ k: 'admin_net', comp: NetmaskField, label: "Admin-panel accessible from", placeholder: "any address",
148
helperText: h(Fragment, {}, "IP address of browser machine. ", h(WildcardsSupported))
149
},
157
- { k: 'mime', comp: StringStringField,
158
- keyLabel: "Files", keyWidth: 7,
159
- valueLabel: "Mime type", valueWidth: 4
150
+ { k: 'mime', comp: ArrayField, label: false, reorder: true, prepend: true,
151
+ fields: [
152
+ { k: 'k', label: "File mask", $width: 1, $column: {
153
+ renderCell: ({ value, id }: any) => h('code', {},
154
+ value,
155
+ value === '*' && id < Object.keys(values.mime).length - 1
156
+ && iconTooltip(Warning, md("Mime with `*` should be the last, because first matching row applies"), {
157
+ color: 'warning.main', ml: 1
158
+ }))
159
+ } },
160
+ { k: 'v', label: "Mime type", $width: 2 },
161
+ ],
162
+ toField: x => Object.entries(x || {}).map(([k,v]) => ({ k, v })),
163
+ fromField: x => Object.fromEntries(x.map((row: any) => [row.k, row.v])),
164
},
165
{ k: 'block', label: "Blocked IPs", multiline: true, minRows:3, helperText: h(Fragment, {}, "Enter an IP address for each line. ", h(WildcardsSupported)),
166
fromField: (all:string) => all.split('\n').map(s => s.trim()).filter(Boolean).map(ip => ({ ip })),
@@ -223,7 +227,7 @@ export function isKeyError(error: any) {
227
return /private key/.test(error)
228
}
229
226
-function ServerPort({ label, value, onChange, getApi, status, suggestedPort=1, error, helperText }: FieldProps<number | null>) {
230
+function ServerPort({ label, value, onChange, setApi, status, suggestedPort=1, error, helperText }: FieldProps<number | null>) {
231
const lastCustom = useRef(suggestedPort)
232
if (value! > 0)
233
lastCustom.current = value!
@@ -253,7 +257,7 @@ function ServerPort({ label, value, onChange, getApi, status, suggestedPort=1, e
257
fullWidth: false,
258
value,
259
onChange,
256
- getApi,
260
+ setApi,
261
error,
262
min: 1,
263
max: 65535,
admin/src/misc.ts
+7
-8
@@ -68,15 +68,14 @@ export const IconBtn = forwardRef(({ title, icon, onClick, disabled, progress, l
68
ret.catch(alertDialog).finally(()=> setLoading(false))
69
}
70
}
71
- }, h(icon))
72
- if ((progress || loading) && progress !== false) // false is also useful to inhibit behavior with loading
73
- ret = h(Box, { position:'relative', display: 'inline-block' },
74
- h(CircularProgress, {
71
+ },
72
+ (progress || loading) && progress !== false // false is also useful to inhibit behavior with loading
73
+ && h(CircularProgress, {
74
...(typeof progress === 'number' ? { value: progress*100, variant: 'determinate' } : null),
75
style: { position:'absolute', top: 4, left: 4, width: 32, height: 32 }
76
}),
78
- ret
79
- )
77
+ h(icon)
78
+ )
79
if (title)
80
ret = h(Tooltip, { title, ...tooltipProps, children: h('span',{},ret) })
81
return ret
@@ -123,8 +122,8 @@ export function Btn({ icon, title, onClick, disabled, progress, link, tooltipPro
122
return ret
123
}
124
126
-export function iconTooltip(icon: SvgIconComponent, tooltip: string, sx?: SxProps) {
127
- return h(Tooltip, { title: tooltip, children: h(icon, { sx }) })
125
+export function iconTooltip(icon: SvgIconComponent, tooltip: ReactNode, sx?: SxProps) {
126
+ return h(Tooltip, { title: tooltip, children: h(icon, { sx: { verticalAlign: 'bottom', ...sx } }) })
127
}
128
129
export function InLink(props:any) {
mui-grid-form/SelectField.ts
+2
-2
@@ -20,7 +20,7 @@ type SelectOption<T> = SelectPair<T> | (T extends string | number ? T : never)
20
interface SelectPair<T> { label: string, value:T }
21
22
export function SelectField<T>(props: FieldProps<T> & CommonSelectProps<T>) {
23
- const { value, onChange, getApi, options, sx, ...rest } = props
23
+ const { value, onChange, setApi, options, sx, ...rest } = props
24
return h(TextField, { // using TextField because Select is not displaying label correctly
25
...commonSelectProps(props),
26
...rest,
@@ -36,7 +36,7 @@ export function SelectField<T>(props: FieldProps<T> & CommonSelectProps<T>) {
36
}
37
38
export function MultiSelectField<T>(props: FieldProps<T[]> & CommonSelectProps<T>) {
39
- const { value, onChange, getApi, options, sx, ...rest } = props
39
+ const { value, onChange, setApi, options, sx, ...rest } = props
40
return h(TextField, {
41
...commonSelectProps({ ...props, value: undefined }),
42
...rest,
mui-grid-form/StringField.ts
+2
-2
@@ -14,9 +14,9 @@ interface StringFieldProps extends FieldProps<string>, Partial<Omit<StandardText
14
start?: ReactNode
15
end?: ReactNode
16
}
17
-export function StringField({ value, onChange, min, max, required, getApi, typing, start, end, onTyping, suggestions, ...props }: StringFieldProps) {
17
+export function StringField({ value, onChange, min, max, required, setApi, typing, start, end, onTyping, suggestions, ...props }: StringFieldProps) {
18
const normalized = value ?? ''
19
- getApi?.({
19
+ setApi?.({
20
getError() {
21
return !value && required ? "required"
22
: value?.length! < min! ? "too short"
mui-grid-form/StringStringField.ts
deleted
-88
@@ -1,88 +0,0 @@
1
-// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
-
3
-import { createElement as h, useRef } from 'react'
4
-import { Grid, IconButton } from '@mui/material'
5
-import { Add, Delete } from '@mui/icons-material'
6
-import { FieldProps} from '.'
7
-import { StringField } from './StringField'
8
-
9
-export function StringStringField({ value, onChange, keyLabel='key', valueLabel='value', keyWidth=5, valueWidth=5, actionsWidth=1 }: FieldProps<Record<string,string>> & { keyLabel:string }) {
10
- const refNew = useRef()
11
- const justEntered = useRef<any>()
12
- const tableHeader = {
13
- padding: '.5em 1em',
14
- fontWeight: 'bold',
15
- }
16
- return h(Grid, { container: true },
17
- // header
18
- h(Grid, { item: true, xs:keyWidth, sx: tableHeader }, keyLabel),
19
- h(Grid, { item: true, xs:valueWidth, sx: tableHeader }, valueLabel),
20
- h(Grid, { item: true, xs:actionsWidth },
21
- h(IconButton, {
22
- onClick() { // @ts-ignore
23
- refNew.current.focus()
24
- }
25
- }, h(Add))),
26
- // existing entries
27
- Object.entries(value||{}).map(([id,v]) => [
28
- h(Grid, { key:'k', item: true, xs: keyWidth, },
29
- h(StringField, {
30
- value: id,
31
- onChange(v, { was, ...rest }){
32
- const copy = { ...value }
33
- if (v)
34
- copy[v] = was !== undefined ? copy[was] : ''
35
- if (was !== undefined)
36
- delete copy[was]
37
- onChange(copy, { was:value, ...rest })
38
- }
39
- })),
40
- h(Grid, { key:'v', item: true, xs: valueWidth, },
41
- h(StringField, {
42
- inputRef(el: HTMLInputElement) {
43
- if (justEntered.current !== id) return
44
- el?.focus()
45
- justEntered.current = null
46
- },
47
- value: v,
48
- onChange(v, { was, ...rest }){
49
- const copy = { ...value }
50
- if (v)
51
- copy[id] = v
52
- else
53
- delete copy[id]
54
- onChange(copy, { was:value, ...rest })
55
- }
56
- })),
57
- h(Grid, { key:'actions', item: true, xs: actionsWidth, sx: { display: 'flex' } },
58
- h(IconButton, {
59
- onClick(event){
60
- const copy = { ...value }
61
- delete copy[id]
62
- onChange(copy, { was:value, event })
63
- }
64
- }, h(Delete)))
65
- ]),
66
- // empty row for adding
67
- h(Grid, { item: true, xs: keyWidth, },
68
- h(StringField, {
69
- inputRef: refNew,
70
- value: '',
71
- onChange(v, more){
72
- if (!v) return
73
- more.cancel()
74
- if (value?.hasOwnProperty(v))
75
- return alert(keyLabel + " entry already present")
76
- justEntered.current = v // the way dom is manipulated will cause focus on wrong element, so we have to re-focus
77
- onChange({ ...value, [v]:'' }, { ...more, was:value })
78
- }
79
- })),
80
- h(Grid, { item: true, xs: valueWidth, },
81
- h(StringField, {
82
- value: '',
83
- onChange(){},
84
- disabled: true,
85
- })),
86
- )
87
-}
88
-
mui-grid-form/index.ts
+9
-10
@@ -22,16 +22,14 @@ import { GridProps } from '@mui/material/Grid/Grid'
22
import { useDebounce } from 'usehooks-ts'
23
export * from './SelectField'
24
export * from './misc-fields'
25
-export * from './StringStringField'
25
export { StringField }
26
27
type ValidationError = ReactNode // false = no error
29
-export interface FieldDescriptor<T=any> {
28
+export interface FieldDescriptor<T=any> extends FieldApi<T> {
29
k: string
30
comp?: any
31
label?: ReactNode
32
error?: ReactNode
34
- getError?: (v: any, extra?: any) => Promisable<ValidationError>
33
toField?: (v: T) => any
34
fromField?: (v: any) => T
35
before?: ReactNode
@@ -43,15 +41,16 @@ export interface FieldDescriptor<T=any> {
41
export type Field<T> = FC<FieldProps<T>>
42
43
export type Promisable<T> = T | Promise<T>
46
-interface FieldApi {
44
+interface FieldApi<T> {
45
// provide getError if you want your error to be visible by the Form component
48
- getError: () => Promisable<ValidationError>, [rest: string]: any
46
+ getError?: (v: any, extra?: any) => Promisable<ValidationError>
47
+ isEqual?: (a: T, b: T) => boolean,
48
}
49
export interface FieldProps<T> {
50
label?: string | ReactElement
51
value?: T
52
onChange: (v: T, more: { was?: T, event: any, [rest: string]: any }) => void
54
- getApi?: (api: FieldApi) => void
53
+ setApi?: (api: FieldApi<T>) => void
54
error?: boolean
55
helperText?: ReactNode
56
[rest: string]: any
@@ -108,7 +107,7 @@ export function Form<Values extends Dict>({
107
const validateUpTo = useRef('')
108
useEffect(() => void(phaseChange()), [phase]) //eslint-disable-line
109
111
- const apis: Dict<FieldApi> = {}
110
+ const apis: Dict<FieldApi<unknown>> = {} // consider { [K in keyof Values]?: FieldApi<Values[K]> }
111
return h('form', {
112
ref: formRef && (x => formRef.current = x ? x as HTMLFormElement : undefined),
113
...formProps,
@@ -142,7 +141,7 @@ export function Form<Values extends Dict>({
141
Object.assign(field, {
142
value: toField(originalValue),
143
error: Boolean(errMsg || error) || undefined,
145
- getApi(api) { apis[k] = api },
144
+ setApi(api) { apis[k] = api },
145
onBlur() {
146
pleaseValidate(k)
147
},
@@ -154,7 +153,7 @@ export function Form<Values extends Dict>({
153
try {
154
v = fromField(v)
155
setFieldExceptions(x => ({ ...x, [k]: false }))
157
- if (_.isEqual(v, originalValue)) return
156
+ if ((apis[k]?.isEqual || _.isEqual)(v, originalValue)) return
157
set(v, k)
158
pleaseValidate(k)
159
}
@@ -231,7 +230,7 @@ export function Form<Values extends Dict>({
230
if (!f || isValidElement(f) || !f.k) continue
231
const { k } = f
232
const v = values?.[k]
234
- const err = await apis[k]?.getError()
233
+ const err = await apis[k]?.getError?.(v, { values, fields })
234
|| await f.getError?.(v, { values, fields })
235
|| fieldExceptions[k]
236
errs[k] = err || false
mui-grid-form/misc-fields.ts
+3
-3
@@ -22,8 +22,8 @@ export function DisplayField({ value, empty='-', ...props }: any) {
22
return h(StringField, { ...props, value, disabled: true })
23
}
24
25
-export function NumberField({ value, onChange, getApi, required, min, max, step, unit, ...props }: FieldProps<number | null>) {
26
- getApi?.({
25
+export function NumberField({ value, onChange, setApi, required, min, max, step, unit, ...props }: FieldProps<number | null>) {
26
+ setApi?.({
27
getError() {
28
return value == null ? (required ? "required" : false)
29
: (value < min ? "too low" : value > max ? "too high" : false)
@@ -52,7 +52,7 @@ export function NumberField({ value, onChange, getApi, required, min, max, step,
52
})
53
}
54
55
-export function BoolField({ label='', value, onChange, getApi, helperText, error,
55
+export function BoolField({ label='', value, onChange, setApi, helperText, error,
56
type, // avoid passing this by accident, as it disrupts the control
57
...props }: FieldProps<boolean>) {
58
const setter = () => value ?? false
shared/index.ts
+19
@@ -201,3 +201,22 @@ export function tryJson(s?: string) {
201
try { return s && JSON.parse(s) }
202
catch {}
203
}
204
+
205
+export function swap<T>(obj: T, k1: keyof T, k2: keyof T) {
206
+ const temp = obj[k1]
207
+ obj[k1] = obj[k2]
208
+ obj[k2] = temp
209
+ return obj
210
+}
211
+
212
+export function isOrderedEqual(a: any, b: any): boolean {
213
+ return _.isEqualWith(a, b, (a1, b1) => {
214
+ if (!_.isPlainObject(a1) || !_.isPlainObject(b1)) return
215
+ const ka = Object.keys(a1)
216
+ const kb = Object.keys(b1)
217
+ return ka.length === kb.length && ka.every((ka1, i) => {
218
+ const kb1 = kb[i]
219
+ return ka1 === kb1 && isOrderedEqual(a1[ka1], b1[kb1])
220
+ })
221
+ })
222
+}
\ No newline at end of file