Skip to content

Commit 0d62d1f

Browse files
feat(table): allow sort by column
Signed-off-by: Luka Trovic <luka@nextcloud.com>
1 parent 2fa0aa2 commit 0d62d1f

4 files changed

Lines changed: 232 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: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,31 @@ function findSameCellInNextRow($cell) {
7272
}
7373
}
7474

75+
const getSortableCellText = (cell) => cell.textContent.trim()
76+
77+
const isTableCellType = (node) =>
78+
node?.type?.name === 'tableCell' || node?.type?.name === 'tableHeader'
79+
80+
const resolveTableCellFromPosition = (doc, position) => {
81+
const tryResolve = (pos) => {
82+
if (typeof pos !== 'number' || pos < 0 || pos > doc.content.size) {
83+
return null
84+
}
85+
const $pos = doc.resolve(pos)
86+
for (let depth = $pos.depth; depth > 0; depth -= 1) {
87+
if (isTableCellType($pos.node(depth))) {
88+
return { $cell: $pos, cellDepth: depth }
89+
}
90+
}
91+
return null
92+
}
93+
94+
return (
95+
tryResolve(position)
96+
|| tryResolve(typeof position === 'number' ? position + 1 : position)
97+
)
98+
}
99+
75100
export default Table.extend({
76101
content: 'tableCaption? tableHeadRow tableRow*',
77102

@@ -181,6 +206,97 @@ export default Table.extend({
181206
)
182207
dispatch(tr.setSelection(selection).scrollIntoView())
183208
}
209+
return true
210+
},
211+
sortColumn:
212+
(direction = 'asc', cell = null) =>
213+
({ state, tr, dispatch }) => {
214+
const resolvedCell = resolveTableCellFromPosition(
215+
state.doc,
216+
cell,
217+
)
218+
if (!resolvedCell) return false
219+
220+
const { $cell, cellDepth } = resolvedCell
221+
const columnIndex = $cell.index(cellDepth - 1)
222+
223+
// find the table node
224+
let tableDepth = $cell.depth
225+
while (
226+
tableDepth > 0
227+
&& $cell.node(tableDepth).type.name !== 'table'
228+
) {
229+
tableDepth -= 1
230+
}
231+
if (tableDepth === 0) return false
232+
233+
const table = $cell.node(tableDepth)
234+
const tablePos = $cell.before(tableDepth)
235+
const bodyRows = []
236+
const nonBodyChildren = []
237+
table.forEach((child) => {
238+
if (child.type.name === 'tableRow') {
239+
bodyRows.push(child)
240+
return
241+
}
242+
nonBodyChildren.push(child)
243+
})
244+
if (bodyRows.length < 2) return true
245+
246+
// check if all rows have a cell at the column index and that the cell doesn't have colspan or rowspan
247+
const canSortRows = bodyRows.every((row) => {
248+
if (columnIndex >= row.childCount) {
249+
return false
250+
}
251+
const targetCell = row.child(columnIndex)
252+
return (
253+
(targetCell.attrs.colspan ?? 1) === 1
254+
&& (targetCell.attrs.rowspan ?? 1) === 1
255+
)
256+
})
257+
if (!canSortRows) return false
258+
259+
// sort the rows based on the content of the cell at the column index
260+
const collator = new Intl.Collator(undefined, {
261+
numeric: true,
262+
sensitivity: 'base',
263+
})
264+
const sortDirection = direction === 'desc' ? -1 : 1
265+
const sortedRows = bodyRows
266+
.map((row, index) => ({
267+
index,
268+
row,
269+
key: getSortableCellText(row.child(columnIndex)),
270+
}))
271+
.sort((a, b) => {
272+
const keyCompare =
273+
collator.compare(a.key, b.key) * sortDirection
274+
if (keyCompare !== 0) {
275+
return keyCompare
276+
}
277+
return a.index - b.index
278+
})
279+
280+
const hasChangedOrder = sortedRows.some(
281+
({ index }, sortedIndex) => index !== sortedIndex,
282+
)
283+
if (!hasChangedOrder) return true
284+
285+
const sortedTable = table.type.createChecked(
286+
table.attrs,
287+
[...nonBodyChildren, ...sortedRows.map(({ row }) => row)],
288+
table.marks,
289+
)
290+
291+
if (dispatch) {
292+
tr.replaceWith(
293+
tablePos,
294+
tablePos + table.nodeSize,
295+
sortedTable,
296+
)
297+
dispatch(tr.scrollIntoView())
298+
}
299+
184300
return true
185301
},
186302
}

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>
@@ -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
@@ -94,6 +112,8 @@ import {
94112
AlignHorizontalCenter,
95113
AlignHorizontalLeft,
96114
AlignHorizontalRight,
115+
SortAscending,
116+
SortDescending,
97117
TableAddColumnAfter,
98118
TableAddColumnBefore,
99119
TrashCan,
@@ -113,6 +133,8 @@ export default {
113133
NodeViewContent,
114134
TableAddColumnBefore,
115135
TableAddColumnAfter,
136+
SortAscending,
137+
SortDescending,
116138
},
117139
props: {
118140
editor: {
@@ -195,6 +217,15 @@ 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+
this.editor.chain().focus().sortColumn(direction, this.getPos()).run()
228+
},
198229
t,
199230
},
200231
}

src/tests/nodes/Table.spec.js

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

287+
const getHeaderCellPos = (editor, targetIndex = 0) => {
288+
let cellPos
289+
editor.state.doc.descendants((node, pos) => {
290+
if (!['tableHeadRow', 'tableRow'].includes(node.type.name)) {
291+
return true
292+
}
293+
if (targetIndex >= node.childCount) {
294+
return false
295+
}
296+
297+
cellPos = pos + 1
298+
for (let index = 0; index < targetIndex; index += 1) {
299+
cellPos += node.child(index).nodeSize
300+
}
301+
return false
302+
})
303+
return cellPos
304+
}
305+
306+
const getBodyColumnValues = (editor, columnIndex) => {
307+
const values = []
308+
editor.state.doc.descendants((node) => {
309+
if (node.type.name !== 'tableRow') {
310+
return true
311+
}
312+
if (columnIndex < node.childCount) {
313+
values.push(node.child(columnIndex).textContent.trim())
314+
}
315+
return true
316+
})
317+
return values
318+
}
319+
241320
const formatHTML = (html) => {
242321
return html.replaceAll('><', '>\n<').replace(/\n$/, '')
243322
}

0 commit comments

Comments
 (0)