Allow currency sorting in tables (#11332)

* Allow currency sorting in tables

* Update shared/editor/commands/table.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Tom Moor
2026-02-01 10:43:10 -05:00
committed by GitHub
parent 9aa666e708
commit 72c2664478
3 changed files with 338 additions and 7 deletions
+46 -7
View File
@@ -20,6 +20,7 @@ import {
} from "prosemirror-tables";
import { ProsemirrorHelper } from "../../utils/ProsemirrorHelper";
import { CSVHelper } from "../../utils/csv";
import { isCurrency, parseCurrency } from "../../utils/currency";
import { parseDate } from "../../utils/date";
import { chainTransactions } from "../lib/chainTransactions";
import {
@@ -336,8 +337,9 @@ export function sortTable({
// column data before sort
const columnData = table.map((row) => row[index]?.textContent ?? "");
// determine sorting type: date, number, or text
// determine sorting type: date, currency, number, or text
let compareAsDate = false;
let compareAsCurrency = false;
let compareAsNumber = false;
const nonEmptyCells = table
@@ -346,11 +348,24 @@ export function sortTable({
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 not dates, check if cells are currency values
// treat as currency if at least 50% of non-empty cells look like currency values
if (!compareAsDate) {
compareAsNumber = nonEmptyCells.every(
const currencyCells = nonEmptyCells.filter((cell) =>
isCurrency(cell)
);
const currencyRatio = currencyCells.length / nonEmptyCells.length;
compareAsCurrency = currencyCells.length >= 2 && currencyRatio >= 0.5;
}
// if not currency, check if cells are numbers (same logic)
if (!compareAsDate && !compareAsCurrency) {
const numberCells = nonEmptyCells.filter(
(cell) => !isNaN(parseFloat(cell))
);
const numberRatio = numberCells.length / nonEmptyCells.length;
compareAsNumber = numberCells.length >= 2 && numberRatio >= 0.5;
}
}
@@ -370,12 +385,36 @@ export function sortTable({
if (compareAsDate) {
const aDate = parseDate(aContent);
const bDate = parseDate(bContent);
if (aDate && bDate) {
return aDate.getTime() - bDate.getTime();
// non-date cells go to the end (like empty cells)
if (!aDate) {
return bDate ? 1 : 0;
}
return 0;
if (!bDate) {
return -1;
}
return aDate.getTime() - bDate.getTime();
} else if (compareAsCurrency) {
const aValue = parseCurrency(aContent);
const bValue = parseCurrency(bContent);
// non-currency cells go to the end (like empty cells)
if (aValue === null) {
return bValue !== null ? 1 : 0;
}
if (bValue === null) {
return -1;
}
return aValue - bValue;
} else if (compareAsNumber) {
return parseFloat(aContent) - parseFloat(bContent);
const aNum = parseFloat(aContent);
const bNum = parseFloat(bContent);
// non-number cells go to the end (like empty cells)
if (isNaN(aNum)) {
return !isNaN(bNum) ? 1 : 0;
}
if (isNaN(bNum)) {
return -1;
}
return aNum - bNum;
} else {
return aContent.localeCompare(bContent);
}
+114
View File
@@ -0,0 +1,114 @@
import { isCurrency, parseCurrency } from "./currency";
describe("isCurrency", () => {
it("recognizes USD format", () => {
expect(isCurrency("$100")).toBe(true);
expect(isCurrency("$400")).toBe(true);
expect(isCurrency("$3000")).toBe(true);
expect(isCurrency("$1,234.56")).toBe(true);
expect(isCurrency("$1234.56")).toBe(true);
});
it("recognizes Euro format", () => {
expect(isCurrency("€100")).toBe(true);
expect(isCurrency("100€")).toBe(true);
expect(isCurrency("€1.234,56")).toBe(true);
expect(isCurrency("1.234,56€")).toBe(true);
});
it("recognizes other currency symbols", () => {
expect(isCurrency("£500")).toBe(true);
expect(isCurrency("¥1000")).toBe(true);
expect(isCurrency("₹50,000")).toBe(true);
expect(isCurrency("R$1.234,56")).toBe(true);
});
it("recognizes negative currency values", () => {
expect(isCurrency("-$100")).toBe(true);
expect(isCurrency("($100)")).toBe(true);
expect(isCurrency("-€50")).toBe(true);
});
it("returns false for non-currency values", () => {
expect(isCurrency("100")).toBe(false);
expect(isCurrency("hello")).toBe(false);
expect(isCurrency("")).toBe(false);
expect(isCurrency("1,234.56")).toBe(false);
});
});
describe("parseCurrency", () => {
it("parses USD format", () => {
expect(parseCurrency("$100")).toBe(100);
expect(parseCurrency("$400")).toBe(400);
expect(parseCurrency("$3000")).toBe(3000);
expect(parseCurrency("$1,234.56")).toBe(1234.56);
expect(parseCurrency("$1234.56")).toBe(1234.56);
expect(parseCurrency("$0.99")).toBe(0.99);
});
it("parses Euro format (European style)", () => {
expect(parseCurrency("€100")).toBe(100);
expect(parseCurrency("100€")).toBe(100);
expect(parseCurrency("€1.234,56")).toBe(1234.56);
expect(parseCurrency("1.234,56€")).toBe(1234.56);
expect(parseCurrency("€1234,56")).toBe(1234.56);
});
it("parses other currencies", () => {
expect(parseCurrency("£500")).toBe(500);
expect(parseCurrency("¥1000")).toBe(1000);
expect(parseCurrency("₹50,000")).toBe(50000);
expect(parseCurrency("R$1.234,56")).toBe(1234.56);
});
it("parses negative values", () => {
expect(parseCurrency("-$100")).toBe(-100);
expect(parseCurrency("($100)")).toBe(-100);
expect(parseCurrency("-€50")).toBe(-50);
expect(parseCurrency("($1,234.56)")).toBe(-1234.56);
});
it("handles whitespace", () => {
expect(parseCurrency(" $100 ")).toBe(100);
expect(parseCurrency("$ 100")).toBe(100);
expect(parseCurrency("100 €")).toBe(100);
});
it("returns null for invalid values", () => {
expect(parseCurrency("")).toBe(null);
expect(parseCurrency("hello")).toBe(null);
expect(parseCurrency("abc$123")).toBe(null);
});
it("handles large numbers", () => {
expect(parseCurrency("$1,000,000.00")).toBe(1000000);
expect(parseCurrency("€1.000.000,00")).toBe(1000000);
});
it("sorts currency values correctly", () => {
const values = ["$400", "$3000", "$100", "$50"];
const sorted = values.sort((a, b) => {
const aVal = parseCurrency(a);
const bVal = parseCurrency(b);
if (aVal !== null && bVal !== null) {
return aVal - bVal;
}
return 0;
});
expect(sorted).toEqual(["$50", "$100", "$400", "$3000"]);
});
it("sorts currency values in descending order correctly", () => {
const values = ["$400", "$3000", "$100", "$50"];
const sorted = values.sort((a, b) => {
const aVal = parseCurrency(a);
const bVal = parseCurrency(b);
if (aVal !== null && bVal !== null) {
return bVal - aVal; // descending
}
return 0;
});
expect(sorted).toEqual(["$3000", "$400", "$100", "$50"]);
});
});
+178
View File
@@ -0,0 +1,178 @@
/**
* Common currency symbols used around the world.
*/
const currencySymbols = [
"$", // Dollar (USD, CAD, AUD, etc.)
"€", // Euro
"£", // Pound
"¥", // Yen/Yuan
"₹", // Indian Rupee
"₽", // Russian Ruble
"₿", // Bitcoin
"₩", // Korean Won
"₪", // Israeli Shekel
"₺", // Turkish Lira
"₴", // Ukrainian Hryvnia
"₱", // Philippine Peso
"฿", // Thai Baht
"₫", // Vietnamese Dong
"₦", // Nigerian Naira
"₵", // Ghanaian Cedi
"₡", // Costa Rican Colón
"₲", // Paraguayan Guaraní
"₸", // Kazakhstani Tenge
"₼", // Azerbaijani Manat
"₾", // Georgian Lari
"৳", // Bangladeshi Taka
"₠", // European Currency Unit
"R$", // Brazilian Real
"kr", // Scandinavian Krona/Krone
"zł", // Polish Zloty
"Kč", // Czech Koruna
"Ft", // Hungarian Forint
"CHF", // Swiss Franc
"лв", // Bulgarian Lev
"lei", // Romanian Leu
"ден", // Macedonian Denar
"дин", // Serbian Dinar
"ر.س", // Saudi Riyal
"د.إ", // UAE Dirham
"ر.ع", // Omani Rial
"د.ك", // Kuwaiti Dinar
"د.ب", // Bahraini Dinar
"ر.ق", // Qatari Riyal
];
/**
* Checks if a string appears to be a currency value.
* Matches formats like: $1,234.56, €50, £1.000,50, -$500, ($500), 1234¥
*
* @param value - the string to check.
* @returns true if the string appears to be a currency value.
*/
export function isCurrency(value: string): boolean {
if (!value || value.trim().length === 0) {
return false;
}
const trimmed = value.trim();
// Must contain at least one currency symbol
const hasCurrencySymbol = currencySymbols.some((symbol) =>
trimmed.includes(symbol)
);
if (!hasCurrencySymbol) {
return false;
}
// Must contain at least one digit
if (!/\d/.test(trimmed)) {
return false;
}
// Remove all valid currency characters and check if anything unexpected remains
let remaining = trimmed;
// Remove currency symbols (longest first to handle multi-char symbols like R$)
const sortedSymbols = [...currencySymbols].sort(
(a, b) => b.length - a.length
);
for (const symbol of sortedSymbols) {
remaining = remaining.split(symbol).join("");
}
// Remove digits, separators, whitespace, and negative indicators
remaining = remaining.replace(/[\d.,\s()\-]/g, "");
// If anything remains, it's not a valid currency
return remaining.length === 0;
}
/**
* Parses a currency string and returns its numeric value.
* Handles various formats including:
* - US/UK style: $1,234.56
* - European style: €1.234,56
* - Negative values: -$500, ($500), -500€
* - Currency symbol before or after the number
*
* @param value - the currency string to parse.
* @returns the numeric value, or null if parsing fails.
*/
export function parseCurrency(value: string): number | null {
if (!value || value.trim().length === 0) {
return null;
}
let trimmed = value.trim();
// Detect negative values: parentheses indicate negative in accounting
const isNegative =
trimmed.startsWith("(") ||
trimmed.startsWith("-") ||
trimmed.includes(")-") ||
(trimmed.endsWith(")") && trimmed.includes("("));
// Remove currency symbols, parentheses, and whitespace
// Sort symbols by length descending so multi-character symbols (like R$) are removed first
let cleaned = trimmed;
const sortedSymbols = [...currencySymbols].sort(
(a, b) => b.length - a.length
);
for (const symbol of sortedSymbols) {
cleaned = cleaned.split(symbol).join("");
}
cleaned = cleaned
.replace(/[()]/g, "")
.replace(/\s/g, "")
.replace(/^-|-$/g, "");
// Determine the decimal separator by looking at the last separator
// European format uses comma as decimal: 1.234,56
// US/UK format uses period as decimal: 1,234.56
const lastComma = cleaned.lastIndexOf(",");
const lastPeriod = cleaned.lastIndexOf(".");
const hasComma = lastComma !== -1;
const hasPeriod = lastPeriod !== -1;
if (hasComma && hasPeriod) {
// Both separators present - the one that appears last is the decimal
if (lastComma > lastPeriod) {
// European format: comma is the decimal separator
// Remove periods (thousands separator) and replace comma with period
cleaned = cleaned.replace(/\./g, "").replace(",", ".");
} else {
// US/UK format: period is the decimal separator
// Remove commas (thousands separator)
cleaned = cleaned.replace(/,/g, "");
}
} else if (hasComma) {
// Only commas present - could be thousands separator or decimal
// If there's exactly one comma and 1-2 digits after it, treat as decimal
const parts = cleaned.split(",");
if (parts.length === 2 && parts[1].length <= 2) {
cleaned = cleaned.replace(",", ".");
} else {
// Multiple commas or 3+ digits after comma = thousands separator
cleaned = cleaned.replace(/,/g, "");
}
} else if (hasPeriod) {
// Only periods present - could be thousands separator or decimal
// If there's exactly one period and 1-2 digits after it, treat as decimal
const parts = cleaned.split(".");
if (parts.length === 2 && parts[1].length <= 2) {
// Already in correct format
} else {
// Multiple periods or 3+ digits after period = thousands separator
cleaned = cleaned.replace(/\./g, "");
}
}
const numericValue = parseFloat(cleaned);
if (isNaN(numericValue)) {
return null;
}
return isNegative ? -Math.abs(numericValue) : numericValue;
}