Skip to content

Commit 87911cd

Browse files
feat(table): allow sort by column
Signed-off-by: Luka Trovic <luka@nextcloud.com>
1 parent cacfd73 commit 87911cd

4 files changed

Lines changed: 207 additions & 2 deletions

File tree

src/components/icons.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ import MDI_TrashCan from 'vue-material-design-icons/TrashCanOutline.vue'
6666
import MDI_Upload from 'vue-material-design-icons/TrayArrowUp.vue'
6767
import MDI_UnfoldMoreHorizontal from 'vue-material-design-icons/UnfoldMoreHorizontal.vue'
6868
import MDI_Web from 'vue-material-design-icons/Web.vue'
69+
import MDI_SortAscending from 'vue-material-design-icons/SortAscending.vue'
70+
import MDI_SortDescending from 'vue-material-design-icons/SortDescending.vue'
6971

7072
const DEFAULT_ICON_SIZE = 20
7173

@@ -152,3 +154,5 @@ export const Warn = makeIcon(MDI_Warn)
152154
export const Web = makeIcon(MDI_Web)
153155
export const Plus = makeIcon(MDI_Plus)
154156
export const Sigma = makeIcon(MDI_Sigma)
157+
export const SortAscending = makeIcon(MDI_SortAscending)
158+
export const SortDescending = makeIcon(MDI_SortDescending)

src/nodes/Table/Table.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ function findSameCellInNextRow($cell) {
7272
}
7373
}
7474

75+
const getSortableCellText = (cell) => cell.textContent.trim()
76+
7577
export default Table.extend({
7678
content: 'tableCaption? tableHeadRow tableRow*',
7779

@@ -181,6 +183,94 @@ export default Table.extend({
181183
)
182184
dispatch(tr.setSelection(selection).scrollIntoView())
183185
}
186+
return true
187+
},
188+
sortColumn:
189+
(direction = 'asc', explicitColumnIndex = null) =>
190+
({ state, tr, dispatch }) => {
191+
if (!isInTable(state)) return false
192+
193+
const $cell = selectionCell(state)
194+
const columnIndex =
195+
typeof explicitColumnIndex === 'number'
196+
? explicitColumnIndex
197+
: $cell.index(-1)
198+
199+
// find the table node
200+
let tableDepth = $cell.depth
201+
while (
202+
tableDepth > 0
203+
&& $cell.node(tableDepth).type.name !== 'table'
204+
) {
205+
tableDepth -= 1
206+
}
207+
if (tableDepth === 0) return false
208+
209+
const table = $cell.node(tableDepth)
210+
const tablePos = $cell.before(tableDepth)
211+
const bodyRows = []
212+
const nonBodyChildren = []
213+
table.forEach((child) => {
214+
if (child.type.name === 'tableRow') {
215+
bodyRows.push(child)
216+
return
217+
}
218+
nonBodyChildren.push(child)
219+
})
220+
if (bodyRows.length < 2) return true
221+
222+
// check if all rows have a cell at the column index and that the cell doesn't have colspan or rowspan
223+
const canSortRows = bodyRows.every((row) => {
224+
if (columnIndex >= row.childCount) {
225+
return false
226+
}
227+
const targetCell = row.child(columnIndex)
228+
return (
229+
(targetCell.attrs.colspan ?? 1) === 1
230+
&& (targetCell.attrs.rowspan ?? 1) === 1
231+
)
232+
})
233+
if (!canSortRows) return false
234+
235+
// sort the rows based on the content of the cell at the column index
236+
const collator = new Intl.Collator(undefined, {
237+
numeric: true,
238+
sensitivity: 'base',
239+
})
240+
const sortDirection = direction === 'desc' ? -1 : 1
241+
const sortedRows = bodyRows
242+
.map((row, index) => ({
243+
index,
244+
row,
245+
key: getSortableCellText(row.child(columnIndex)),
246+
}))
247+
.sort((a, b) => {
248+
const keyCompare = collator.compare(a.key, b.key) * sortDirection
249+
if (keyCompare !== 0) {
250+
return keyCompare
251+
}
252+
return a.index - b.index
253+
})
254+
255+
const hasChangedOrder = sortedRows.some(
256+
({ index }, sortedIndex) => index !== sortedIndex,
257+
)
258+
if (!hasChangedOrder) return true
259+
260+
const sortedTable = table.type.createChecked(
261+
table.attrs,
262+
[
263+
...nonBodyChildren,
264+
...sortedRows.map(({ row }) => row),
265+
],
266+
table.marks,
267+
)
268+
269+
if (dispatch) {
270+
tr.replaceWith(tablePos, tablePos + table.nodeSize, sortedTable)
271+
dispatch(tr.scrollIntoView())
272+
}
273+
184274
return true
185275
},
186276
}

src/nodes/Table/TableHeaderView.vue

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<!--
2-
- SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
3-
- SPDX-License-Identifier: AGPL-3.0-or-later
2+
- SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
44
-->
55

66
<template>
@@ -52,6 +52,24 @@
5252
</template>
5353
</NcActionButton>
5454
</NcActionButtonGroup>
55+
<NcActionButton
56+
data-text-table-action="sort-column-asc"
57+
close-after-click
58+
@click="sortColumnAsc">
59+
<template #icon>
60+
<SortAscending />
61+
</template>
62+
{{ t('text', 'Sort ascending') }}
63+
</NcActionButton>
64+
<NcActionButton
65+
data-text-table-action="sort-column-desc"
66+
close-after-click
67+
@click="sortColumnDesc">
68+
<template #icon>
69+
<SortDescending />
70+
</template>
71+
{{ t('text', 'Sort descending') }}
72+
</NcActionButton>
5573
<NcActionButton
5674
data-text-table-action="add-column-before"
5775
close-after-click
@@ -97,6 +115,8 @@ import {
97115
TableAddColumnAfter,
98116
TableAddColumnBefore,
99117
TrashCan,
118+
SortAscending,
119+
SortDescending,
100120
} from '../../components/icons.js'
101121
102122
export default {
@@ -113,6 +133,8 @@ export default {
113133
NodeViewContent,
114134
TableAddColumnBefore,
115135
TableAddColumnAfter,
136+
SortAscending,
137+
SortDescending,
116138
},
117139
props: {
118140
editor: {
@@ -195,6 +217,25 @@ export default {
195217
.addColumnAfter()
196218
.run()
197219
},
220+
sortColumnAsc() {
221+
this.sortColumn('asc')
222+
},
223+
sortColumnDesc() {
224+
this.sortColumn('desc')
225+
},
226+
sortColumn(direction) {
227+
const columnIndex = this.getHeaderColumnIndex()
228+
this.editor
229+
.chain()
230+
.focus()
231+
.setTextSelection(this.getPos())
232+
.sortColumn(direction, columnIndex)
233+
.run()
234+
},
235+
getHeaderColumnIndex() {
236+
const $pos = this.editor.state.doc.resolve(this.getPos())
237+
return $pos.index()
238+
},
198239
t,
199240
},
200241
}

src/tests/nodes/Table.spec.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,78 @@ describe('Table extension', () => {
236236
expect(editor.getHTML()).toBe(editorHtml)
237237
}
238238
})
239+
240+
test('sorts table body rows in ascending order by selected column', ({ editor }) => {
241+
editor.commands.setContent(
242+
markdownit.render('| col0 | col1 |\n|---|---|\n| 2 | b |\n| 10 | a |\n| 1 | c |\n'),
243+
)
244+
245+
selectHeaderCell(editor, 0)
246+
expect(editor.commands.sortColumn('asc', 0)).toBe(true)
247+
248+
expect(getBodyColumnValues(editor, 0)).toEqual(['1', '2', '10'])
249+
expect(getBodyColumnValues(editor, 1)).toEqual(['c', 'b', 'a'])
250+
251+
selectHeaderCell(editor, 1)
252+
expect(editor.commands.sortColumn('asc', 1)).toBe(true)
253+
254+
expect(getBodyColumnValues(editor, 0)).toEqual(['10', '2', '1'])
255+
expect(getBodyColumnValues(editor, 1)).toEqual(['a', 'b', 'c'])
256+
})
257+
258+
test('sorts table body rows in descending order by selected column', ({ editor }) => {
259+
editor.commands.setContent(
260+
markdownit.render('| col0 | col1 |\n|---|---|\n| 2 | b |\n| 10 | a |\n| 1 | c |\n'),
261+
)
262+
263+
selectHeaderCell(editor, 0)
264+
expect(editor.commands.sortColumn('desc', 0)).toBe(true)
265+
266+
expect(getBodyColumnValues(editor, 0)).toEqual(['10', '2', '1'])
267+
expect(getBodyColumnValues(editor, 1)).toEqual(['a', 'b', 'c'])
268+
269+
selectHeaderCell(editor, 1)
270+
expect(editor.commands.sortColumn('desc', 1)).toBe(true)
271+
272+
expect(getBodyColumnValues(editor, 0)).toEqual(['1', '2', '10'])
273+
expect(getBodyColumnValues(editor, 1)).toEqual(['c', 'b', 'a'])
274+
})
239275
})
240276

277+
const selectHeaderCell = (editor, targetIndex = 0) => {
278+
let currentIndex = 0
279+
let selectionPos
280+
editor.state.doc.descendants((node, pos) => {
281+
if (node.type.name !== 'tableHeader') {
282+
return true
283+
}
284+
if (currentIndex === targetIndex) {
285+
selectionPos = pos + 1
286+
return false
287+
}
288+
currentIndex += 1
289+
return true
290+
})
291+
if (selectionPos != null) {
292+
editor.commands.setTextSelection(selectionPos)
293+
}
294+
return selectionPos
295+
}
296+
297+
const getBodyColumnValues = (editor, columnIndex) => {
298+
const values = []
299+
editor.state.doc.descendants((node) => {
300+
if (node.type.name !== 'tableRow') {
301+
return true
302+
}
303+
if (columnIndex < node.childCount) {
304+
values.push(node.child(columnIndex).textContent.trim())
305+
}
306+
return true
307+
})
308+
return values
309+
}
310+
241311
const formatHTML = (html) => {
242312
return html.replaceAll('><', '>\n<').replace(/\n$/, '')
243313
}

0 commit comments

Comments
 (0)