Skip to content

Commit 29fbcb5

Browse files
authored
fix(b-table): sort handling for numeric string values (closes #6092) (#6105)
* fix(b-table): sort handling for numeric string values * Update stringify-object-values.spec.js * Update stringify-object-values.spec.js
1 parent 2700ebd commit 29fbcb5

7 files changed

+180
-96
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,62 @@
11
import get from '../../../utils/get'
2-
import { isDate, isFunction, isNumber, isUndefinedOrNull } from '../../../utils/inspect'
3-
import stringifyObjectValues from './stringify-object-values'
2+
import stringifyObjectValues from '../../../utils/stringify-object-values'
3+
import { isDate, isFunction, isNumber, isNumeric, isUndefinedOrNull } from '../../../utils/inspect'
4+
import { toFloat } from '../../../utils/number'
5+
6+
const normalizeValue = value => {
7+
if (isUndefinedOrNull(value)) {
8+
return ''
9+
}
10+
if (isNumeric(value)) {
11+
return toFloat(value)
12+
}
13+
return value
14+
}
415

516
// Default sort compare routine
617
//
7-
// TODO: Add option to sort by multiple columns (tri-state per column,
8-
// plus order of columns in sort) where sortBy could be an array
9-
// of objects `[ {key: 'foo', sortDir: 'asc'}, {key:'bar', sortDir: 'desc'} ...]`
10-
// or an array of arrays `[ ['foo','asc'], ['bar','desc'] ]`
11-
// Multisort will most likely be handled in mixin-sort.js by
12-
// calling this method for each sortBy
13-
const defaultSortCompare = (a, b, sortBy, sortDesc, formatter, localeOpts, locale, nullLast) => {
18+
// TODO:
19+
// Add option to sort by multiple columns (tri-state per column,
20+
// plus order of columns in sort) where `sortBy` could be an array
21+
// of objects `[ {key: 'foo', sortDir: 'asc'}, {key:'bar', sortDir: 'desc'} ...]`
22+
// or an array of arrays `[ ['foo','asc'], ['bar','desc'] ]`
23+
// Multisort will most likely be handled in `mixin-sort.js` by
24+
// calling this method for each sortBy
25+
const defaultSortCompare = (
26+
a,
27+
b,
28+
{ sortBy = null, formatter = null, locale = undefined, localeOptions = {}, nullLast = false } = {}
29+
) => {
30+
// Get the value by `sortBy`
1431
let aa = get(a, sortBy, null)
1532
let bb = get(b, sortBy, null)
33+
34+
// Apply user-provided formatter
1635
if (isFunction(formatter)) {
1736
aa = formatter(aa, sortBy, a)
1837
bb = formatter(bb, sortBy, b)
1938
}
20-
aa = isUndefinedOrNull(aa) ? '' : aa
21-
bb = isUndefinedOrNull(bb) ? '' : bb
39+
40+
// Internally normalize value
41+
// `null` / `undefined` => ''
42+
// `'0'` => `0`
43+
aa = normalizeValue(aa)
44+
bb = normalizeValue(bb)
45+
2246
if ((isDate(aa) && isDate(bb)) || (isNumber(aa) && isNumber(bb))) {
2347
// Special case for comparing dates and numbers
2448
// Internally dates are compared via their epoch number values
2549
return aa < bb ? -1 : aa > bb ? 1 : 0
2650
} else if (nullLast && aa === '' && bb !== '') {
27-
// Special case when sorting null/undefined/empty string last
51+
// Special case when sorting `null` / `undefined` / '' last
2852
return 1
2953
} else if (nullLast && aa !== '' && bb === '') {
30-
// Special case when sorting null/undefined/empty string last
54+
// Special case when sorting `null` / `undefined` / '' last
3155
return -1
3256
}
57+
3358
// Do localized string comparison
34-
return stringifyObjectValues(aa).localeCompare(stringifyObjectValues(bb), locale, localeOpts)
59+
return stringifyObjectValues(aa).localeCompare(stringifyObjectValues(bb), locale, localeOptions)
3560
}
3661

3762
export default defaultSortCompare

src/components/table/helpers/default-sort-compare.spec.js

+53-44
Original file line numberDiff line numberDiff line change
@@ -2,47 +2,53 @@ import defaultSortCompare from './default-sort-compare'
22

33
describe('table/helpers/default-sort-compare', () => {
44
it('sorts numbers correctly', async () => {
5-
expect(defaultSortCompare({ a: 1 }, { a: 2 }, 'a')).toBe(-1)
6-
expect(defaultSortCompare({ a: 2 }, { a: 1 }, 'a')).toBe(1)
7-
expect(defaultSortCompare({ a: 1 }, { a: 1 }, 'a')).toBe(0)
8-
expect(defaultSortCompare({ a: -1 }, { a: 1 }, 'a')).toBe(-1)
9-
expect(defaultSortCompare({ a: 1 }, { a: -1 }, 'a')).toBe(1)
10-
expect(defaultSortCompare({ a: 0 }, { a: 0 }, 'a')).toBe(0)
11-
expect(defaultSortCompare({ a: 1.234 }, { a: 1.567 }, 'a')).toBe(-1)
12-
expect(defaultSortCompare({ a: 1.561 }, { a: 1.234 }, 'a')).toBe(1)
5+
const options = { sortBy: 'a' }
6+
expect(defaultSortCompare({ a: 1 }, { a: 2 }, options)).toBe(-1)
7+
expect(defaultSortCompare({ a: 2 }, { a: 1 }, options)).toBe(1)
8+
expect(defaultSortCompare({ a: 1 }, { a: 1 }, options)).toBe(0)
9+
expect(defaultSortCompare({ a: -1 }, { a: 1 }, options)).toBe(-1)
10+
expect(defaultSortCompare({ a: 1 }, { a: -1 }, options)).toBe(1)
11+
expect(defaultSortCompare({ a: 0 }, { a: 0 }, options)).toBe(0)
12+
expect(defaultSortCompare({ a: 1.234 }, { a: 1.567 }, options)).toBe(-1)
13+
expect(defaultSortCompare({ a: 1.561 }, { a: 1.234 }, options)).toBe(1)
1314
})
1415

1516
it('sorts dates correctly', async () => {
1617
const date1 = { a: new Date(2020, 1, 1) }
1718
const date2 = { a: new Date(1999, 11, 31) }
1819
const date3 = { a: new Date(1999, 1, 1) }
1920
const date4 = { a: new Date(1999, 1, 1, 12, 12, 12, 12) }
21+
const options = { sortBy: 'a' }
2022

21-
expect(defaultSortCompare(date1, date2, 'a')).toBe(1)
22-
expect(defaultSortCompare(date1, date1, 'a')).toBe(0)
23-
expect(defaultSortCompare(date2, date1, 'a')).toBe(-1)
24-
expect(defaultSortCompare(date2, date3, 'a')).toBe(1)
25-
expect(defaultSortCompare(date3, date2, 'a')).toBe(-1)
26-
expect(defaultSortCompare(date3, date4, 'a')).toBe(-1)
27-
expect(defaultSortCompare(date4, date3, 'a')).toBe(1)
28-
expect(defaultSortCompare(date4, date4, 'a')).toBe(0)
23+
expect(defaultSortCompare(date1, date2, options)).toBe(1)
24+
expect(defaultSortCompare(date1, date1, options)).toBe(0)
25+
expect(defaultSortCompare(date2, date1, options)).toBe(-1)
26+
expect(defaultSortCompare(date2, date3, options)).toBe(1)
27+
expect(defaultSortCompare(date3, date2, options)).toBe(-1)
28+
expect(defaultSortCompare(date3, date4, options)).toBe(-1)
29+
expect(defaultSortCompare(date4, date3, options)).toBe(1)
30+
expect(defaultSortCompare(date4, date4, options)).toBe(0)
2931
})
3032

3133
it('sorts strings correctly', async () => {
34+
const options = { sortBy: 'a' }
35+
3236
// Note: string comparisons are locale based
33-
expect(defaultSortCompare({ a: 'a' }, { a: 'b' }, 'a')).toBe(-1)
34-
expect(defaultSortCompare({ a: 'b' }, { a: 'a' }, 'a')).toBe(1)
35-
expect(defaultSortCompare({ a: 'a' }, { a: 'a' }, 'a')).toBe(0)
36-
expect(defaultSortCompare({ a: 'a' }, { a: 'aaa' }, 'a')).toBe(-1)
37-
expect(defaultSortCompare({ a: 'aaa' }, { a: 'a' }, 'a')).toBe(1)
37+
expect(defaultSortCompare({ a: 'a' }, { a: 'b' }, options)).toBe(-1)
38+
expect(defaultSortCompare({ a: 'b' }, { a: 'a' }, options)).toBe(1)
39+
expect(defaultSortCompare({ a: 'a' }, { a: 'a' }, options)).toBe(0)
40+
expect(defaultSortCompare({ a: 'a' }, { a: 'aaa' }, options)).toBe(-1)
41+
expect(defaultSortCompare({ a: 'aaa' }, { a: 'a' }, options)).toBe(1)
3842
})
3943

4044
it('sorts by nested key correctly', async () => {
45+
const options = { sortBy: 'a.b' }
46+
4147
// Note: string comparisons are locale based
42-
expect(defaultSortCompare({ a: { b: 'a' } }, { a: { b: 'b' } }, 'a.b')).toBe(-1)
43-
expect(defaultSortCompare({ a: { b: 'b' } }, { a: { b: 'a' } }, 'a.b')).toBe(1)
44-
expect(defaultSortCompare({ a: { b: 'a' } }, { a: { b: 'a' } }, 'a.b')).toBe(0)
45-
expect(defaultSortCompare({ a: { b: 'a' } }, { a: { b: 'aaa' } }, 'a.b')).toBe(-1)
48+
expect(defaultSortCompare({ a: { b: 'a' } }, { a: { b: 'b' } }, options)).toBe(-1)
49+
expect(defaultSortCompare({ a: { b: 'b' } }, { a: { b: 'a' } }, options)).toBe(1)
50+
expect(defaultSortCompare({ a: { b: 'a' } }, { a: { b: 'a' } }, options)).toBe(0)
51+
expect(defaultSortCompare({ a: { b: 'a' } }, { a: { b: 'aaa' } }, options)).toBe(-1)
4652
})
4753

4854
it('sorts using provided formatter correctly', async () => {
@@ -53,34 +59,37 @@ describe('table/helpers/default-sort-compare', () => {
5359
.reverse()
5460
.join('')
5561
}
56-
expect(defaultSortCompare({ a: 'ab' }, { a: 'b' }, 'a')).toBe(-1)
57-
expect(defaultSortCompare({ a: 'ab' }, { a: 'b' }, 'a', false, formatter)).toBe(1)
62+
63+
expect(defaultSortCompare({ a: 'ab' }, { a: 'b' }, { sortBy: 'a' })).toBe(-1)
64+
expect(defaultSortCompare({ a: 'ab' }, { a: 'b' }, { sortBy: 'a', formatter })).toBe(1)
5865
})
5966

6067
it('sorts nulls always last when sor-null-lasst is set', async () => {
6168
const x = { a: 'ab' }
6269
const y = { a: null }
6370
const z = {}
6471
const w = { a: '' }
65-
const u = undefined
72+
const options = { sortBy: 'a', localeOptions: { numeric: true } }
73+
const optionsNullLast = { ...options, nullLast: true }
6674

6775
// Without nullLast set (false)
68-
expect(defaultSortCompare(x, y, 'a', u, u, { numeric: true }, u, false)).toBe(1)
69-
expect(defaultSortCompare(y, x, 'a', u, u, { numeric: true }, u, false)).toBe(-1)
70-
expect(defaultSortCompare(x, z, 'a', u, u, { numeric: true }, u, false)).toBe(1)
71-
expect(defaultSortCompare(z, x, 'a', u, u, { numeric: true }, u, false)).toBe(-1)
72-
expect(defaultSortCompare(y, z, 'a', u, u, { numeric: true }, u, false)).toBe(0)
73-
expect(defaultSortCompare(z, y, 'a', u, u, { numeric: true }, u, false)).toBe(0)
74-
expect(defaultSortCompare(x, w, 'a', u, u, { numeric: true }, u, false)).toBe(1)
75-
expect(defaultSortCompare(w, x, 'a', u, u, { numeric: true }, u, false)).toBe(-1)
76+
expect(defaultSortCompare(x, y, options)).toBe(1)
77+
expect(defaultSortCompare(y, x, options)).toBe(-1)
78+
expect(defaultSortCompare(x, z, options)).toBe(1)
79+
expect(defaultSortCompare(z, x, options)).toBe(-1)
80+
expect(defaultSortCompare(y, z, options)).toBe(0)
81+
expect(defaultSortCompare(z, y, options)).toBe(0)
82+
expect(defaultSortCompare(x, w, options)).toBe(1)
83+
expect(defaultSortCompare(w, x, options)).toBe(-1)
84+
7685
// With nullLast set
77-
expect(defaultSortCompare(x, y, 'a', u, u, { numeric: true }, u, true)).toBe(-1)
78-
expect(defaultSortCompare(y, x, 'a', u, u, { numeric: true }, u, true)).toBe(1)
79-
expect(defaultSortCompare(x, z, 'a', u, u, { numeric: true }, u, true)).toBe(-1)
80-
expect(defaultSortCompare(z, x, 'a', u, u, { numeric: true }, u, true)).toBe(1)
81-
expect(defaultSortCompare(y, z, 'a', u, u, { numeric: true }, u, true)).toBe(0)
82-
expect(defaultSortCompare(z, y, 'a', u, u, { numeric: true }, u, true)).toBe(0)
83-
expect(defaultSortCompare(x, w, 'a', u, u, { numeric: true }, u, true)).toBe(-1)
84-
expect(defaultSortCompare(w, x, 'a', u, u, { numeric: true }, u, true)).toBe(1)
86+
expect(defaultSortCompare(x, y, optionsNullLast)).toBe(-1)
87+
expect(defaultSortCompare(y, x, optionsNullLast)).toBe(1)
88+
expect(defaultSortCompare(x, z, optionsNullLast)).toBe(-1)
89+
expect(defaultSortCompare(z, x, optionsNullLast)).toBe(1)
90+
expect(defaultSortCompare(y, z, optionsNullLast)).toBe(0)
91+
expect(defaultSortCompare(z, y, optionsNullLast)).toBe(0)
92+
expect(defaultSortCompare(x, w, optionsNullLast)).toBe(-1)
93+
expect(defaultSortCompare(w, x, optionsNullLast)).toBe(1)
8594
})
8695
})

src/components/table/helpers/mixin-sorting.js

+26-23
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,7 @@ export default {
3838
// Supported localCompare options, see `options` section of:
3939
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare
4040
type: Object,
41-
default: () => {
42-
return { numeric: true }
43-
}
41+
default: () => ({ numeric: true })
4442
},
4543
sortCompareLocale: {
4644
// String: locale code
@@ -102,17 +100,20 @@ export default {
102100
isSortable() {
103101
return this.computedFields.some(f => f.sortable)
104102
},
103+
// Sorts the filtered items and returns a new array of the sorted items
104+
// When not sorted, the original items array will be returned
105105
sortedItems() {
106-
// Sorts the filtered items and returns a new array of the sorted items
107-
// or the original items array if not sorted.
106+
const {
107+
localSortBy: sortBy,
108+
localSortDesc: sortDesc,
109+
sortCompareLocale: locale,
110+
sortNullLast: nullLast,
111+
sortCompare,
112+
localSorting
113+
} = this
108114
const items = (this.filteredItems || this.localItems || []).slice()
109-
const sortBy = this.localSortBy
110-
const sortDesc = this.localSortDesc
111-
const sortCompare = this.sortCompare
112-
const localSorting = this.localSorting
113-
const sortOptions = { ...this.sortCompareOptions, usage: 'sort' }
114-
const sortLocale = this.sortCompareLocale || undefined
115-
const nullLast = this.sortNullLast
115+
const localeOptions = { ...this.sortCompareOptions, usage: 'sort' }
116+
116117
if (sortBy && localSorting) {
117118
const field = this.computedFieldsObj[sortBy] || {}
118119
const sortByFormatted = field.sortByFormatted
@@ -121,31 +122,33 @@ export default {
121122
: sortByFormatted
122123
? this.getFieldFormatter(sortBy)
123124
: undefined
125+
124126
// `stableSort` returns a new array, and leaves the original array intact
125127
return stableSort(items, (a, b) => {
126128
let result = null
129+
// Call user provided `sortCompare` routine first
127130
if (isFunction(sortCompare)) {
128-
// Call user provided sortCompare routine
129-
result = sortCompare(a, b, sortBy, sortDesc, formatter, sortOptions, sortLocale)
131+
// TODO:
132+
// Change the `sortCompare` signature to the one of `defaultSortCompare`
133+
// with the next major version bump
134+
result = sortCompare(a, b, sortBy, sortDesc, formatter, localeOptions, locale)
130135
}
136+
// Fallback to built-in `defaultSortCompare` if `sortCompare`
137+
// is not defined or returns `null`/`false`
131138
if (isUndefinedOrNull(result) || result === false) {
132-
// Fallback to built-in defaultSortCompare if sortCompare
133-
// is not defined or returns null/false
134-
result = defaultSortCompare(
135-
a,
136-
b,
139+
result = defaultSortCompare(a, b, {
137140
sortBy,
138-
sortDesc,
139141
formatter,
140-
sortOptions,
141-
sortLocale,
142+
locale,
143+
localeOptions,
142144
nullLast
143-
)
145+
})
144146
}
145147
// Negate result if sorting in descending order
146148
return (result || 0) * (sortDesc ? -1 : 1)
147149
})
148150
}
151+
149152
return items
150153
}
151154
},

src/components/table/helpers/stringify-record-values.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { isObject } from '../../../utils/inspect'
2+
import stringifyObjectValues from '../../../utils/stringify-object-values'
23
import sanitizeRow from './sanitize-row'
3-
import stringifyObjectValues from './stringify-object-values'
44

55
// Stringifies the values of a record, ignoring any special top level field keys
66
// TODO: Add option to stringify `scopedSlot` items

src/components/table/table-sorting.spec.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ describe('table > sorting', () => {
240240
sortDesc: false,
241241
sortCompare: (a, b, sortBy) => {
242242
// We just use our default sort compare to test passing a function
243-
return defaultSortCompare(a, b, sortBy)
243+
return defaultSortCompare(a, b, { sortBy })
244244
}
245245
}
246246
})
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { keys } from '../../../utils/object'
2-
import { isDate, isObject, isUndefinedOrNull } from '../../../utils/inspect'
3-
import { toString } from '../../../utils/string'
1+
import { isDate, isObject, isUndefinedOrNull } from './inspect'
2+
import { keys } from './object'
3+
import { toString } from './string'
44

55
// Recursively stringifies the values of an object, space separated, in an
66
// SSR safe deterministic way (keys are sorted before stringification)
@@ -10,24 +10,24 @@ import { toString } from '../../../utils/string'
1010
// becomes
1111
// 'one 3 2 zzz 10 12 11'
1212
//
13-
// Primitives (numbers/strings) are returned as-is
14-
// Null and undefined values are filtered out
13+
// Strings are returned as-is
14+
// Numbers get converted to string
15+
// `null` and `undefined` values are filtered out
1516
// Dates are converted to their native string format
16-
const stringifyObjectValues = val => {
17-
if (isUndefinedOrNull(val)) {
18-
/* istanbul ignore next */
17+
const stringifyObjectValues = value => {
18+
if (isUndefinedOrNull(value)) {
1919
return ''
2020
}
2121
// Arrays are also object, and keys just returns the array indexes
2222
// Date objects we convert to strings
23-
if (isObject(val) && !isDate(val)) {
24-
return keys(val)
23+
if (isObject(value) && !isDate(value)) {
24+
return keys(value)
2525
.sort() // Sort to prevent SSR issues on pre-rendered sorted tables
26-
.filter(v => !isUndefinedOrNull(v)) // Ignore undefined/null values
27-
.map(k => stringifyObjectValues(val[k]))
26+
.map(k => stringifyObjectValues(value[k]))
27+
.filter(v => !!v) // Ignore empty strings
2828
.join(' ')
2929
}
30-
return toString(val)
30+
return toString(value)
3131
}
3232

3333
export default stringifyObjectValues

0 commit comments

Comments
 (0)