Skip to content

Commit 2069083

Browse files
authored
feat(b-table): add multi column sorting support
1 parent 6774800 commit 2069083

File tree

1 file changed

+156
-71
lines changed

1 file changed

+156
-71
lines changed

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

Lines changed: 156 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,35 @@
11
import stableSort from '../../../utils/stable-sort'
22
import startCase from '../../../utils/startcase'
3-
import { arrayIncludes } from '../../../utils/array'
4-
import { isFunction, isUndefinedOrNull } from '../../../utils/inspect'
3+
import looseEqual from '../../../utils/loose-equal'
4+
import { arrayIncludes, concat } from '../../../utils/array'
5+
import { isBoolean, isFunction, isNumber, isUndefined, isUndefinedOrNull } from '../../../utils/inspect'
56
import defaultSortCompare from './default-sort-compare'
67

78
export default {
89
props: {
910
sortBy: {
10-
type: String,
11+
// Array for multi-sort (prop sort-multi must be set to true)
12+
type: [String, Array],
1113
default: ''
1214
},
1315
sortDesc: {
14-
// TODO: Make this tri-state: true, false, null
16+
// Array for multi-sort. Must be same length as sortBy
17+
// (prop sort-multi must be set to true)
18+
// TODO: Support tri-state: true, false, null
19+
type: [Boolean, Array],
20+
default: false
21+
},
22+
sortMulti: {
23+
// Enables multi-columns sorting When set, also implies tri-state sorting
1524
type: Boolean,
1625
default: false
1726
},
1827
sortDirection: {
1928
// This prop is named incorrectly
20-
// It should be `initialSortDirection` as it is a bit misleading
21-
// (not to mention it screws up the ARIA label on the headers)
29+
// It should be `initialSortDirection` as it is misleading
30+
// (not to mention it screws up computing the ARIA label on the headers)
31+
// It (last) should probably be deprecated in favour of multi-sort, as
32+
// 'last' will make multi-sort very bloated!
2233
type: String,
2334
default: 'asc',
2435
validator: direction => arrayIncludes(['asc', 'desc', 'last'], direction)
@@ -82,8 +93,8 @@ export default {
8293
},
8394
data() {
8495
return {
85-
localSortBy: this.sortBy || '',
86-
localSortDesc: this.sortDesc || false
96+
localSortBy: [''],
97+
localSortDesc: [false]
8798
}
8899
},
89100
computed: {
@@ -93,48 +104,75 @@ export default {
93104
isSortable() {
94105
return this.computedFields.some(f => f.sortable)
95106
},
107+
computedSortBy() {
108+
return this.sortMulti ? this.localSortBy : this.localSortBy.slice(0, 1)
109+
},
110+
computedSortDesc() {
111+
// Ensure values are tri-state (true, false, null), and same length as localSortBy array
112+
const sortDesc = this.localSortDesc
113+
return this.computedSortBy.map((_, idx) => (isBoolean(sortDesc[idx]) ? sortDesc[idx] : null))
114+
},
115+
computedSortInfo() {
116+
// TODO in this PR:
117+
// Make sortInfo a data property which we update
118+
// via watchers on sortBy/SortDesc and event handlers.
119+
// When a column is no longer sorted (null) we remove it from the array
120+
return this.computedSortBy
121+
.map((key, idx) => {
122+
const field = this.computedFieldsObj[key] || {}
123+
return {
124+
sortBy: key,
125+
sortDesc: this.computedSortDesc[idx],
126+
formatter: isFunction(field.formatter)
127+
? field.formatter
128+
: field.formatter
129+
? this.getFieldFormatter(key)
130+
: undefined
131+
}
132+
})
133+
.filter(x => x.sortBy && isBoolean(x.sortDesc))
134+
},
96135
sortedItems() {
97136
// Sorts the filtered items and returns a new array of the sorted items
98137
// or the original items array if not sorted.
99138
const items = (this.filteredItems || this.localItems || []).slice()
100-
const sortBy = this.localSortBy
101-
const sortDesc = this.localSortDesc
139+
const sortInfo = this.computedSortInfo
102140
const sortCompare = this.sortCompare
103141
const localSorting = this.localSorting
104142
const sortOptions = { ...this.sortCompareOptions, usage: 'sort' }
105143
const sortLocale = this.sortCompareLocale || undefined
106144
const nullLast = this.sortNullLast
107-
if (sortBy && localSorting) {
108-
const field = this.computedFieldsObj[sortBy] || {}
109-
const sortByFormatted = field.sortByFormatted
110-
const formatter = isFunction(sortByFormatted)
111-
? sortByFormatted
112-
: sortByFormatted
113-
? this.getFieldFormatter(sortBy)
114-
: undefined
145+
if (sortInfo && sortInfo.length > 0 && localSorting) {
115146
// `stableSort` returns a new array, and leaves the original array intact
116147
return stableSort(items, (a, b) => {
117-
let result = null
118-
if (isFunction(sortCompare)) {
119-
// Call user provided sortCompare routine
120-
result = sortCompare(a, b, sortBy, sortDesc, formatter, sortOptions, sortLocale)
148+
let result = 0
149+
for (let idx = 0; idx < sortInfo.length && !result; idx++) {
150+
const formatter = sortInfo[idx].formatter
151+
const sortBy = sortInfo[idx].sortBy
152+
const sortDesc = sortInfo[idx].sortDesc
153+
let value = null
154+
if (isFunction(sortCompare)) {
155+
// Call user provided sortCompare routine
156+
value = sortCompare(a, b, sortBy, sortDesc, formatter, sortOptions, sortLocale)
157+
}
158+
if (!isNumber(value)) {
159+
// Fallback to built-in defaultSortCompare if sortCompare
160+
// is not defined or doesn't return a number
161+
value = defaultSortCompare(
162+
a,
163+
b,
164+
sortBy,
165+
sortDesc,
166+
formatter,
167+
sortOptions,
168+
sortLocale,
169+
nullLast
170+
)
171+
}
172+
// Negate result if sorting if descending order
173+
result = (value || 0) * (sortDesc[idx] ? -1 : 1)
121174
}
122-
if (isUndefinedOrNull(result) || result === false) {
123-
// Fallback to built-in defaultSortCompare if sortCompare
124-
// is not defined or returns null/false
125-
result = defaultSortCompare(
126-
a,
127-
b,
128-
sortBy,
129-
sortDesc,
130-
formatter,
131-
sortOptions,
132-
sortLocale,
133-
nullLast
134-
)
135-
}
136-
// Negate result if sorting in descending order
137-
return (result || 0) * (sortDesc ? -1 : 1)
175+
return result
138176
})
139177
}
140178
return items
@@ -143,37 +181,49 @@ export default {
143181
watch: {
144182
isSortable(newVal, oldVal) /* istanbul ignore next: pain in the butt to test */ {
145183
if (newVal) {
146-
if (this.isSortable) {
147-
this.$on('head-clicked', this.handleSort)
148-
}
184+
this.$on('head-clicked', this.handleSort)
149185
} else {
150186
this.$off('head-clicked', this.handleSort)
151187
}
152188
},
153-
sortDesc(newVal, oldVal) {
154-
if (newVal === this.localSortDesc) {
155-
/* istanbul ignore next */
156-
return
189+
sortBy: {
190+
immediate: true,
191+
handler(newVal, oldVal) {
192+
if (looseEqual(newVal, this.localSortBy)) {
193+
/* istanbul ignore next */
194+
return
195+
}
196+
// TODO in this PR:
197+
// Update sortInfo object
198+
this.localSortBy = newVal ? concat(newVal) : []
157199
}
158-
this.localSortDesc = newVal || false
159200
},
160-
sortBy(newVal, oldVal) {
161-
if (newVal === this.localSortBy) {
162-
/* istanbul ignore next */
163-
return
201+
sortDesc: {
202+
immediate: true,
203+
handler(newVal, oldVal) {
204+
if (looseEqual(newVal, this.localSortDesc)) {
205+
/* istanbul ignore next */
206+
return
207+
}
208+
// TODO in this PR:
209+
// Update sortInfo object
210+
newVal = isUndefined(newVal) ? [] : concat(newVal)
211+
this.localSortDesc = newVal.map(d => (isBoolean(d) ? d : null))
164212
}
165-
this.localSortBy = newVal || ''
166213
},
167214
// Update .sync props
215+
// TODO in this PR:
216+
// Instead watch the sortInfo array, and emit the apropriate
217+
// updated values as needed
168218
localSortDesc(newVal, oldVal) {
169219
// Emit update to sort-desc.sync
170-
if (newVal !== oldVal) {
171-
this.$emit('update:sortDesc', newVal)
220+
if (!looseEqual(newVal, oldVal)) {
221+
this.$emit('update:sortDesc', this.sortMulti ? newVal : newVal[0])
172222
}
173223
},
174224
localSortBy(newVal, oldVal) {
175-
if (newVal !== oldVal) {
176-
this.$emit('update:sortBy', newVal)
225+
if (!looseEqual(newVal, oldVal)) {
226+
this.$emit('update:sortBy', this.sortMulti ? newVal : newVal[0])
177227
}
178228
}
179229
},
@@ -184,7 +234,6 @@ export default {
184234
},
185235
methods: {
186236
// Handlers
187-
// Need to move from thead-mixin
188237
handleSort(key, field, evt, isFoot) {
189238
if (!this.isSortable) {
190239
/* istanbul ignore next */
@@ -193,35 +242,48 @@ export default {
193242
if (isFoot && this.noFooterSorting) {
194243
return
195244
}
196-
// TODO: make this tri-state sorting
245+
// TODO in this PR:
246+
// Make a sequence of sorting cycle based on this.sortDirection
247+
// i.e. [true, false, null] vs [false, true, null]
248+
// const sequence = this.sortDirection === 'desc' ? [true, false, null] : [false, true, null]
249+
// TODO in this PR:
250+
// Handle this via lookup
251+
const sortBy = this.localSortBy[0]
252+
// TODO: make this tri-state sorting (for multi-sort only)
197253
// cycle desc => asc => none => desc => ...
198254
let sortChanged = false
199255
const toggleLocalSortDesc = () => {
200256
const sortDirection = field.sortDirection || this.sortDirection
201257
if (sortDirection === 'asc') {
202-
this.localSortDesc = false
258+
this.localSortDesc = [false]
203259
} else if (sortDirection === 'desc') {
204-
this.localSortDesc = true
260+
this.localSortDesc = [true]
205261
} else {
206262
// sortDirection === 'last'
207263
// Leave at last sort direction from previous column
264+
// TODO in this PR:
265+
// If multi-sort, then use sort sequence
208266
}
209267
}
210268
if (field.sortable) {
211-
if (key === this.localSortBy) {
269+
// TODO in this PR:
270+
// handle toggling on the index
271+
if (key === sortBy) {
212272
// Change sorting direction on current column
213-
this.localSortDesc = !this.localSortDesc
273+
this.localSortDesc[0] = !this.localSortDesc[0]
214274
} else {
215275
// Start sorting this column ascending
216-
this.localSortBy = key
276+
this.localSortBy[0] = key
217277
// this.localSortDesc = false
218278
toggleLocalSortDesc()
219279
}
220280
sortChanged = true
221281
} else if (this.localSortBy && !this.noSortReset) {
222-
this.localSortBy = ''
282+
this.localSortBy = ['']
223283
toggleLocalSortDesc()
224284
sortChanged = true
285+
// } else {
286+
// sortChanged = false
225287
}
226288
if (sortChanged) {
227289
// Sorting parameters changed
@@ -242,6 +304,19 @@ export default {
242304
return {}
243305
}
244306
const sortable = field.sortable
307+
// TODO in this PR:
308+
// This is just temporary. Will need to lookup the index of this
309+
// key in this.localSortBy or this.localSortInfo
310+
const sortOrder = 1
311+
// TODO for this PR:
312+
// Find index of key in this.computedSortBy (or this.computedSortInfo),
313+
// and then grab the sorting value
314+
const sortDesc = this.localSortDesc[0]
315+
// TODO in this PR:
316+
// This is just temporary. Will need to lookup if this column key is
317+
// in this.localSortBy
318+
const sortBy = this.localSortBy[0]
319+
// Now we get down to business determining the aria-label value
245320
let ariaLabel = ''
246321
if ((!field.label || !field.label.trim()) && !field.headerTitle) {
247322
// In case field's label and title are empty/blank, we need to
@@ -251,17 +326,18 @@ export default {
251326
/* istanbul ignore next */
252327
ariaLabel = startCase(key)
253328
}
254-
// The correctness of these labels is very important for screen-reader users.
329+
// The correctness of these labels is very important for screen-reader users,
330+
// and should sync up with the aria-sort attributes
255331
let ariaLabelSorting = ''
256332
if (sortable) {
257-
if (this.localSortBy === key) {
333+
if (key === sortBy) {
258334
// currently sorted sortable column.
259-
ariaLabelSorting = this.localSortDesc ? this.labelSortAsc : this.labelSortDesc
335+
ariaLabelSorting = sortDesc ? this.labelSortAsc : this.labelSortDesc
260336
} else {
261337
// Not currently sorted sortable column.
262338
// Not using nested ternary's here for clarity/readability
263339
// Default for ariaLabel
264-
ariaLabelSorting = this.localSortDesc ? this.labelSortDesc : this.labelSortAsc
340+
ariaLabelSorting = sortDesc ? this.labelSortDesc : this.labelSortAsc
265341
// Handle sortDirection setting
266342
const sortDirection = this.sortDirection || field.sortDirection
267343
if (sortDirection === 'asc') {
@@ -278,18 +354,27 @@ export default {
278354
ariaLabel = [ariaLabel.trim(), ariaLabelSorting.trim()].filter(Boolean).join(': ')
279355
// Assemble the aria-sort attribute value
280356
const ariaSort =
281-
sortable && this.localSortBy === key
282-
? this.localSortDesc
357+
sortable && key === sortBy
358+
? sortDesc
283359
? 'descending'
284360
: 'ascending'
285361
: sortable
286362
? 'none'
287363
: null
288364
// Return the attributes
289-
// (All the above just to get these two values)
290365
return {
366+
// All the above just to get these two values correct :(
291367
'aria-label': ariaLabel || null,
292-
'aria-sort': ariaSort
368+
'aria-sort': ariaSort,
369+
// Add indication as to which order the columns are sorted by (numeric)
370+
// This will be placed as a mini pill badge on the top of the field header
371+
// TODO in this PR:
372+
// Add sort index (1-based) if multi sort.
373+
// Also, add prop to disable showing the sort order
374+
// And figure out how to set a variant for this badge
375+
// May require additiona CSS generation. or we just make 2 options: dark and light
376+
// Or use the column text variant (currentColor) to make an outline badge
377+
'data-sort-order': this.sortMulti ? String(sortOrder) : null
293378
}
294379
}
295380
}

0 commit comments

Comments
 (0)