mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user