Skip to content

Commit 4014168

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

4 files changed

Lines changed: 225 additions & 2 deletions

File tree

src/components/icons.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ import MDI_Pencil from 'vue-material-design-icons/PencilOutline.vue'
5555
import MDI_Plus from 'vue-material-design-icons/Plus.vue'
5656
import MDI_Shape from 'vue-material-design-icons/ShapeOutline.vue'
5757
import MDI_Sigma from 'vue-material-design-icons/Sigma.vue'
58+
import MDI_SortAscending from 'vue-material-design-icons/SortAscending.vue'
59+
import MDI_SortDescending from 'vue-material-design-icons/SortDescending.vue'
5860
import MDI_Table from 'vue-material-design-icons/Table.vue'
5961
import MDI_TableSettings from 'vue-material-design-icons/TableCog.vue'
6062
import MDI_TableAddColumnAfter from 'vue-material-design-icons/TableColumnPlusAfter.vue'
@@ -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: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,118 @@ export default Table.extend({
181181
)
182182
dispatch(tr.setSelection(selection).scrollIntoView())
183183
}
184+
return true
185+
},
186+
sortColumn:
187+
(direction = 'asc', cell = null) =>
188+
({ state, tr, dispatch }) => {
189+
if (
190+
cell?.type?.name !== 'tableCell'
191+
&& cell?.type?.name !== 'tableHeader'
192+
) {
193+
return false
194+
}
195+
196+
// find the table, its position and the column index of the cell
197+
let table = null
198+
let tablePos = null
199+
let columnIndex = -1
200+
201+
state.doc.descendants((node, pos) => {
202+
if (node.type.name !== 'table') {
203+
return true
204+
}
205+
206+
for (
207+
let rowIndex = 0;
208+
rowIndex < node.childCount;
209+
rowIndex += 1
210+
) {
211+
const row = node.child(rowIndex)
212+
for (
213+
let currentColumnIndex = 0;
214+
currentColumnIndex < row.childCount;
215+
currentColumnIndex += 1
216+
) {
217+
if (row.child(currentColumnIndex) === cell) {
218+
table = node
219+
tablePos = pos
220+
columnIndex = currentColumnIndex
221+
return false
222+
}
223+
}
224+
}
225+
226+
return true
227+
})
228+
229+
if (!table || tablePos === null || columnIndex < 0) return false
230+
231+
const bodyRows = []
232+
const nonBodyChildren = []
233+
table.forEach((child) => {
234+
if (child.type.name === 'tableRow') {
235+
bodyRows.push(child)
236+
return
237+
}
238+
nonBodyChildren.push(child)
239+
})
240+
if (bodyRows.length < 2) return true
241+
242+
// check if all rows have a cell at the column index and that the cell doesn't have colspan or rowspan
243+
const canSortRows = bodyRows.every((row) => {
244+
if (columnIndex >= row.childCount) {
245+
return false
246+
}
247+
const targetCell = row.child(columnIndex)
248+
return (
249+
(targetCell.attrs.colspan ?? 1) === 1
250+
&& (targetCell.attrs.rowspan ?? 1) === 1
251+
)
252+
})
253+
if (!canSortRows) return false
254+
255+
// sort the rows based on the content of the cell at the column index
256+
const collator = new Intl.Collator(undefined, {
257+
numeric: true,
258+
sensitivity: 'base',
259+
})
260+
const sortDirection = direction === 'desc' ? -1 : 1
261+
const sortedRows = bodyRows
262+
.map((row, index) => ({
263+
index,
264+
row,
265+
key: row.child(columnIndex).textContent.trim(),
266+
}))
267+
.sort((a, b) => {
268+
const keyCompare =
269+
collator.compare(a.key, b.key) * sortDirection
270+
if (keyCompare !== 0) {
271+
return keyCompare
272+
}
273+
return a.index - b.index
274+
})
275+
276+
const hasChangedOrder = sortedRows.some(
277+
({ index }, sortedIndex) => index !== sortedIndex,
278+
)
279+
if (!hasChangedOrder) return true
280+
281+
const sortedTable = table.type.createChecked(
282+
table.attrs,
283+
[...nonBodyChildren, ...sortedRows.map(({ row }) => row)],
284+
table.marks,
285+
)
286+
287+
if (dispatch) {
288+
tr.replaceWith(
289+
tablePos,
290+
tablePos + table.nodeSize,
291+
sortedTable,
292+
)
293+
dispatch(tr.scrollIntoView())
294+
}
295+
184296
return true
185297
},
186298
}

src/nodes/Table/TableHeaderView.vue

Lines changed: 33 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>
@@ -48,6 +48,24 @@
4848
</template>
4949
</NcActionButton>
5050
</NcActionButtonGroup>
51+
<NcActionButton
52+
data-text-table-action="sort-column-asc"
53+
close-after-click
54+
@click="sortColumnAsc">
55+
<template #icon>
56+
<SortAscending />
57+
</template>
58+
{{ t('text', 'Sort ascending') }}
59+
</NcActionButton>
60+
<NcActionButton
61+
data-text-table-action="sort-column-desc"
62+
close-after-click
63+
@click="sortColumnDesc">
64+
<template #icon>
65+
<SortDescending />
66+
</template>
67+
{{ t('text', 'Sort descending') }}
68+
</NcActionButton>
5169
<NcActionButton
5270
data-text-table-action="add-column-before"
5371
close-after-click
@@ -90,6 +108,8 @@ import {
90108
AlignHorizontalCenter,
91109
AlignHorizontalLeft,
92110
AlignHorizontalRight,
111+
SortAscending,
112+
SortDescending,
93113
TableAddColumnAfter,
94114
TableAddColumnBefore,
95115
TrashCan,
@@ -109,6 +129,8 @@ export default {
109129
NodeViewContent,
110130
TableAddColumnBefore,
111131
TableAddColumnAfter,
132+
SortAscending,
133+
SortDescending,
112134
},
113135
props: {
114136
editor: {
@@ -191,6 +213,15 @@ export default {
191213
.addColumnAfter()
192214
.run()
193215
},
216+
sortColumnAsc() {
217+
this.sortColumn('asc')
218+
},
219+
sortColumnDesc() {
220+
this.sortColumn('desc')
221+
},
222+
sortColumn(direction) {
223+
this.editor.commands.sortColumn(direction, this.node)
224+
},
194225
t,
195226
},
196227
}

src/tests/nodes/Table.spec.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,84 @@ describe('Table extension', () => {
224224
expect(editor.getHTML()).toBe(editorHtml)
225225
}
226226
})
227+
228+
test('sorts table body rows in ascending order by selected column', ({
229+
editor,
230+
}) => {
231+
editor.commands.setContent(
232+
markdownit.render(
233+
'| col0 | col1 |\n|---|---|\n| 2 | b |\n| 10 | a |\n| 1 | c |\n',
234+
),
235+
)
236+
237+
let cell
238+
cell = getHeaderCell(editor, 0)
239+
expect(editor.commands.sortColumn('asc', cell)).toBe(true)
240+
241+
expect(getBodyColumnValues(editor, 0)).toEqual(['1', '2', '10'])
242+
expect(getBodyColumnValues(editor, 1)).toEqual(['c', 'b', 'a'])
243+
244+
cell = getHeaderCell(editor, 1)
245+
expect(editor.commands.sortColumn('asc', cell)).toBe(true)
246+
247+
expect(getBodyColumnValues(editor, 0)).toEqual(['10', '2', '1'])
248+
expect(getBodyColumnValues(editor, 1)).toEqual(['a', 'b', 'c'])
249+
})
250+
251+
test('sorts table body rows in descending order by selected column', ({
252+
editor,
253+
}) => {
254+
editor.commands.setContent(
255+
markdownit.render(
256+
'| col0 | col1 |\n|---|---|\n| 2 | b |\n| 10 | a |\n| 1 | c |\n',
257+
),
258+
)
259+
260+
let cell
261+
cell = getHeaderCell(editor, 0)
262+
expect(editor.commands.sortColumn('desc', cell)).toBe(true)
263+
264+
expect(getBodyColumnValues(editor, 0)).toEqual(['10', '2', '1'])
265+
expect(getBodyColumnValues(editor, 1)).toEqual(['a', 'b', 'c'])
266+
267+
cell = getHeaderCell(editor, 1)
268+
expect(editor.commands.sortColumn('desc', cell)).toBe(true)
269+
270+
expect(getBodyColumnValues(editor, 0)).toEqual(['1', '2', '10'])
271+
expect(getBodyColumnValues(editor, 1)).toEqual(['c', 'b', 'a'])
272+
})
227273
})
228274

275+
const getHeaderCell = (editor, targetIndex = 0) => {
276+
let cell
277+
editor.state.doc.descendants((node, pos) => {
278+
if (!['tableHeadRow', 'tableRow'].includes(node.type.name)) {
279+
return true
280+
}
281+
if (targetIndex >= node.childCount) {
282+
return false
283+
}
284+
285+
cell = node.child(targetIndex)
286+
return false
287+
})
288+
return cell
289+
}
290+
291+
const getBodyColumnValues = (editor, columnIndex) => {
292+
const values = []
293+
editor.state.doc.descendants((node) => {
294+
if (node.type.name !== 'tableRow') {
295+
return true
296+
}
297+
if (columnIndex < node.childCount) {
298+
values.push(node.child(columnIndex).textContent.trim())
299+
}
300+
return true
301+
})
302+
return values
303+
}
304+
229305
const formatHTML = (html) => {
230306
return html.replaceAll('><', '>\n<').replace(/\n$/, '')
231307
}

0 commit comments

Comments
 (0)