Skip to content

Commit d5f42ce

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

File tree

4 files changed

+217
-2
lines changed

4 files changed

+217
-2
lines changed

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: 92 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,96 @@ 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 =
249+
collator.compare(a.key, b.key) * sortDirection
250+
if (keyCompare !== 0) {
251+
return keyCompare
252+
}
253+
return a.index - b.index
254+
})
255+
256+
const hasChangedOrder = sortedRows.some(
257+
({ index }, sortedIndex) => index !== sortedIndex,
258+
)
259+
if (!hasChangedOrder) return true
260+
261+
const sortedTable = table.type.createChecked(
262+
table.attrs,
263+
[...nonBodyChildren, ...sortedRows.map(({ row }) => row)],
264+
table.marks,
265+
)
266+
267+
if (dispatch) {
268+
tr.replaceWith(
269+
tablePos,
270+
tablePos + table.nodeSize,
271+
sortedTable,
272+
)
273+
dispatch(tr.scrollIntoView())
274+
}
275+
184276
return true
185277
},
186278
}

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
@@ -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,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: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,86 @@ 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+
selectHeaderCell(editor, 0)
250+
expect(editor.commands.sortColumn('asc', 0)).toBe(true)
251+
252+
expect(getBodyColumnValues(editor, 0)).toEqual(['1', '2', '10'])
253+
expect(getBodyColumnValues(editor, 1)).toEqual(['c', 'b', 'a'])
254+
255+
selectHeaderCell(editor, 1)
256+
expect(editor.commands.sortColumn('asc', 1)).toBe(true)
257+
258+
expect(getBodyColumnValues(editor, 0)).toEqual(['10', '2', '1'])
259+
expect(getBodyColumnValues(editor, 1)).toEqual(['a', 'b', 'c'])
260+
})
261+
262+
test('sorts table body rows in descending order by selected column', ({
263+
editor,
264+
}) => {
265+
editor.commands.setContent(
266+
markdownit.render(
267+
'| col0 | col1 |\n|---|---|\n| 2 | b |\n| 10 | a |\n| 1 | c |\n',
268+
),
269+
)
270+
271+
selectHeaderCell(editor, 0)
272+
expect(editor.commands.sortColumn('desc', 0)).toBe(true)
273+
274+
expect(getBodyColumnValues(editor, 0)).toEqual(['10', '2', '1'])
275+
expect(getBodyColumnValues(editor, 1)).toEqual(['a', 'b', 'c'])
276+
277+
selectHeaderCell(editor, 1)
278+
expect(editor.commands.sortColumn('desc', 1)).toBe(true)
279+
280+
expect(getBodyColumnValues(editor, 0)).toEqual(['1', '2', '10'])
281+
expect(getBodyColumnValues(editor, 1)).toEqual(['c', 'b', 'a'])
282+
})
239283
})
240284

285+
const selectHeaderCell = (editor, targetIndex = 0) => {
286+
let currentIndex = 0
287+
let selectionPos
288+
editor.state.doc.descendants((node, pos) => {
289+
if (node.type.name !== 'tableHeader') {
290+
return true
291+
}
292+
if (currentIndex === targetIndex) {
293+
selectionPos = pos + 1
294+
return false
295+
}
296+
currentIndex += 1
297+
return true
298+
})
299+
if (selectionPos != null) {
300+
editor.commands.setTextSelection(selectionPos)
301+
}
302+
return selectionPos
303+
}
304+
305+
const getBodyColumnValues = (editor, columnIndex) => {
306+
const values = []
307+
editor.state.doc.descendants((node) => {
308+
if (node.type.name !== 'tableRow') {
309+
return true
310+
}
311+
if (columnIndex < node.childCount) {
312+
values.push(node.child(columnIndex).textContent.trim())
313+
}
314+
return true
315+
})
316+
return values
317+
}
318+
241319
const formatHTML = (html) => {
242320
return html.replaceAll('><', '>\n<').replace(/\n$/, '')
243321
}

0 commit comments

Comments
 (0)