fix: add date sorting support to table columns (#11198)

* fix: add date sorting support to table columns

* fix: fixed lint errors, removed unnecessary non-null check

* fix: European slash format should not always be preferred

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Luca Wimmer
2026-01-17 22:32:35 +01:00
committed by GitHub
parent 7b9e1b1c57
commit 06938561a6
2 changed files with 135 additions and 14 deletions
+40 -14
View File
@@ -19,6 +19,7 @@ import {
} from "prosemirror-tables";
import { ProsemirrorHelper } from "../../utils/ProsemirrorHelper";
import { CSVHelper } from "../../utils/csv";
import { parseDate } from "../../utils/date";
import { chainTransactions } from "../lib/chainTransactions";
import {
getAllSelectedColumns,
@@ -297,12 +298,6 @@ export function sortTable({
table.push(cells);
}
// check if all the cells in the column are a number
const compareAsText = table.some((row) => {
const cell = row[index]?.textContent;
return cell === "" ? false : isNaN(parseFloat(cell));
});
const hasHeaderRow = table[0].every(
(cell) => cell.type === state.schema.nodes.th
);
@@ -313,17 +308,48 @@ export function sortTable({
// column data before sort
const columnData = table.map((row) => row[index]?.textContent ?? "");
// determine sorting type: date, number, or text
let compareAsDate = false;
let compareAsNumber = false;
const nonEmptyCells = table
.map((row) => row[index]?.textContent?.trim())
.filter((cell): cell is string => !!cell && cell.length > 0);
if (nonEmptyCells.length > 0) {
// check if all non-empty cells are valid dates
compareAsDate = nonEmptyCells.every((cell) => parseDate(cell) !== null);
// if not dates, check if all non-empty cells are numbers
if (!compareAsDate) {
compareAsNumber = nonEmptyCells.every(
(cell) => !isNaN(parseFloat(cell))
);
}
}
// sort table data based on column at index
table.sort((a, b) => {
if (compareAsText) {
return (a[index]?.textContent ?? "").localeCompare(
b[index]?.textContent ?? ""
);
const aContent = a[index]?.textContent ?? "";
const bContent = b[index]?.textContent ?? "";
// empty cells always go to the end
if (!aContent) {
return bContent ? 1 : 0;
}
if (!bContent) {
return -1;
}
if (compareAsDate) {
const aDate = parseDate(aContent);
const bDate = parseDate(bContent);
if (aDate && bDate) {
return aDate.getTime() - bDate.getTime();
}
return 0;
} else if (compareAsNumber) {
return parseFloat(aContent) - parseFloat(bContent);
} else {
return (
parseFloat(a[index]?.textContent ?? "") -
parseFloat(b[index]?.textContent ?? "")
);
return aContent.localeCompare(bContent);
}
});
+95
View File
@@ -7,6 +7,8 @@ import {
subMonths,
subWeeks,
subYears,
isValid,
parse,
} from "date-fns";
import {
cs,
@@ -33,6 +35,99 @@ import {
zhTW,
} from "date-fns/locale";
import type { DateFilter } from "../types";
import { isBrowser } from "./browser";
/**
* Determines if the user's locale uses month-first date format (MM/dd).
*
* @returns true if locale uses MM/dd format, false for dd/MM format.
*/
export function usesMonthFirstFormat(): boolean {
if (!isBrowser || typeof Intl === "undefined") {
return false;
}
// Format a known date and check if month comes before day
const formatted = new Intl.DateTimeFormat(undefined, {
month: "2-digit",
day: "2-digit",
}).format(new Date(2000, 11, 25)); // Dec 25, 2000
// If it starts with "12", month comes first
return formatted.startsWith("12");
}
/**
* Attempts to parse a date string in various common formats.
*
* @param dateStr The date string to parse.
* @returns a Date object if parsing is successful, null otherwise.
*/
export function parseDate(dateStr: string): Date | null {
if (!dateStr) {
return null;
}
// Remove any trailing alphabetic text (e.g., "Uhr", "at", "o'clock", etc.)
const cleaned = dateStr.trim().replace(/\s*[a-zA-Z]+\s*$/, "");
const monthFirst = [
"MM/dd/yyyy HH:mm:ss",
"MM/dd/yyyy HH:mm",
"MM/dd/yyyy",
"MM/dd HH:mm:ss",
"MM/dd HH:mm",
"MM/dd",
];
const dayFirst = [
"dd/MM/yyyy HH:mm:ss",
"dd/MM/yyyy HH:mm",
"dd/MM/yyyy",
"dd/MM HH:mm:ss",
"dd/MM HH:mm",
"dd/MM",
];
// Ambiguous slash formats - order based on user's locale
const slashFormats = usesMonthFirstFormat()
? [...monthFirst, ...dayFirst]
: [...dayFirst, ...monthFirst];
// Common date formats used in tables (with and without time, with and without year)
const formats = [
// ISO formats
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd HH:mm",
"yyyy-MM-dd",
// European dot formats
"dd.MM.yyyy HH:mm:ss",
"dd.MM.yyyy HH:mm",
"dd.MM.yyyy",
"dd.MM. HH:mm:ss",
"dd.MM. HH:mm",
"dd.MM.",
"d.M.yyyy HH:mm:ss",
"d.M.yyyy HH:mm",
"d.M.yyyy",
"d.M. HH:mm:ss",
"d.M. HH:mm",
"d.M.",
// Locale-dependent slash formats
...slashFormats,
];
const referenceDate = new Date();
for (const format of formats) {
const date = parse(cleaned, format, referenceDate);
if (isValid(date)) {
return date;
}
}
return null;
}
export function subtractDate(date: Date, period: DateFilter) {
switch (period) {