Skip to content

Commit

Permalink
Merge pull request #238 from quadratichq/development-number-formatting
Browse files Browse the repository at this point in the history
feat: Number Formatting
  • Loading branch information
davidkircos committed Feb 10, 2023
2 parents 7124a88 + ae46e9c commit 2932910
Show file tree
Hide file tree
Showing 26 changed files with 480 additions and 222 deletions.
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"localforage": "^1.10.0",
"lodash.debounce": "^4.0.8",
"monaco-editor": "^0.31.1",
"numerable": "^0.3.15",
"pixi-viewport": "^4.37.0",
"pixi.js": "^6.5.1",
"quadratic-core": "file:quadratic-core/pkg",
Expand Down
21 changes: 21 additions & 0 deletions src/core/formatting/cellTextFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const DEFAULT_NUMBER_OF_DECIMAL_PLACES = 2;

export type CellTextFormat =
| {
type: 'NUMBER';
decimalPlaces?: number;
}
| {
type: 'CURRENCY';
display: 'CURRENCY';
symbol?: string;
decimalPlaces?: number;
}
| {
type: 'PERCENTAGE';
decimalPlaces?: number;
}
| {
type: 'EXPONENTIAL';
decimalPlaces?: number;
};
59 changes: 59 additions & 0 deletions src/core/formatting/cellTextFormatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Cell, CellFormat } from '../gridDB/gridTypes';
import { CellTextFormat } from './cellTextFormat';
import { CellTextFormatter } from './cellTextFormatter';

const generateCell = (value: string) => {
return {
x: 0,
y: 0,
type: 'TEXT',
value: value,
} as Cell;
};

const generateFormat = (textFormat: CellTextFormat, decimals?: number) => {
// allows tests to fit on one line below
let format = {
textFormat,
} as CellFormat;
if (decimals && format.textFormat) {
format.textFormat.decimalPlaces = decimals;
}
return format;
};

test('CellTextFormatter', () => {
// format undefined
expect(CellTextFormatter(generateCell('$1'), undefined)).toBe('$1');
expect(CellTextFormatter(generateCell('100%'), undefined)).toBe('100%');
expect(CellTextFormatter(generateCell('hello'), undefined)).toBe('hello');

// format number
expect(CellTextFormatter(generateCell('1'), generateFormat({ type: 'NUMBER' }))).toBe('1.00');
expect(CellTextFormatter(generateCell('1'), generateFormat({ type: 'NUMBER', decimalPlaces: 0 }))).toBe('1');
expect(CellTextFormatter(generateCell('1'), generateFormat({ type: 'NUMBER', decimalPlaces: 1 }))).toBe('1.0');
expect(CellTextFormatter(generateCell('1'), generateFormat({ type: 'NUMBER', decimalPlaces: 4 }))).toBe('1.0000');
expect(CellTextFormatter(generateCell('0.009'), generateFormat({ type: 'NUMBER', decimalPlaces: 2 }))).toBe('0.01');
expect(CellTextFormatter(generateCell('1000000'), generateFormat({ type: 'NUMBER' }))).toBe('1,000,000.00');

// format currency
const currencyFormat = generateFormat({ type: 'CURRENCY', display: 'CURRENCY', symbol: 'USD' });
expect(CellTextFormatter(generateCell('1'), currencyFormat)).toBe('$1.00');
expect(CellTextFormatter(generateCell('.01'), currencyFormat)).toBe('$0.01');
expect(CellTextFormatter(generateCell('.009'), currencyFormat)).toBe('$0.01');
expect(CellTextFormatter(generateCell('1000'), currencyFormat)).toBe('$1,000.00');

// format percentage
expect(CellTextFormatter(generateCell('1'), generateFormat({ type: 'PERCENTAGE' }))).toBe('100.00%');
expect(CellTextFormatter(generateCell('.1'), generateFormat({ type: 'PERCENTAGE' }))).toBe('10.00%');
expect(CellTextFormatter(generateCell('.01'), generateFormat({ type: 'PERCENTAGE' }))).toBe('1.00%');
expect(CellTextFormatter(generateCell('.00009'), generateFormat({ type: 'PERCENTAGE' }))).toBe('0.01%');

// format exponential
expect(CellTextFormatter(generateCell('1'), generateFormat({ type: 'EXPONENTIAL' }))).toBe('1.00e+0');
expect(CellTextFormatter(generateCell('1'), generateFormat({ type: 'EXPONENTIAL', decimalPlaces: 0 }))).toBe('1e+0');
expect(CellTextFormatter(generateCell('1'), generateFormat({ type: 'EXPONENTIAL' }, 1))).toBe('1.0e+0');
expect(CellTextFormatter(generateCell('1'), generateFormat({ type: 'EXPONENTIAL' }, 4))).toBe('1.0000e+0');
expect(CellTextFormatter(generateCell('0.009'), generateFormat({ type: 'EXPONENTIAL' }, 2))).toBe('9.00e-3');
expect(CellTextFormatter(generateCell('1000000'), generateFormat({ type: 'EXPONENTIAL' }))).toBe('1.00e+6');
});
39 changes: 39 additions & 0 deletions src/core/formatting/cellTextFormatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { format as formatNumber } from 'numerable';
import { Cell, CellFormat } from '../gridDB/gridTypes';
import { CellTextFormat, DEFAULT_NUMBER_OF_DECIMAL_PLACES } from './cellTextFormat';

// function that checks if a string is a number
const IsNumeric = (num: string) => /^-{0,1}\d*\.{0,1}\d+$/.test(num);

const getDecimalPlacesString = (format: CellTextFormat, number_of_decimals: number) => {
// returns a string of the format '.00' for the number of decimal places
if (number_of_decimals === 0) return '';
let decimalString = '.';
for (let i = 0; i < number_of_decimals; i++) {
decimalString += '0';
}
return decimalString;
};

export const CellTextFormatter = (cell: Cell, format: CellFormat | undefined) => {
if (!format || !format.textFormat) return cell.value;

const number_of_decimals = format.textFormat.decimalPlaces ?? DEFAULT_NUMBER_OF_DECIMAL_PLACES;
const decimal_string = getDecimalPlacesString(format.textFormat, number_of_decimals);

try {
if (format.textFormat.type === 'CURRENCY' && IsNumeric(cell.value)) {
return formatNumber(cell.value, `$0,0${decimal_string}`, { currency: format.textFormat.symbol });
} else if (format.textFormat.type === 'PERCENTAGE' && IsNumeric(cell.value)) {
return formatNumber(Number(cell.value), `0,0${decimal_string}%`);
} else if (format.textFormat.type === 'NUMBER' && IsNumeric(cell.value)) {
return formatNumber(cell.value, `0,0${decimal_string}`);
} else if (format.textFormat.type === 'EXPONENTIAL' && IsNumeric(cell.value)) {
return Number(cell.value).toExponential(number_of_decimals);
}
} catch (e) {
console.error('Caught error in CellTextFormatter: ', e);
}

return cell.value;
};
2 changes: 2 additions & 0 deletions src/core/gridDB/gridTypes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cellEvaluationReturnType } from '../computations/types';
import { CellTextFormat } from '../formatting/cellTextFormat';

export type CellTypes = 'TEXT' | 'FORMULA' | 'JAVASCRIPT' | 'PYTHON' | 'SQL' | 'COMPUTED';

Expand Down Expand Up @@ -37,6 +38,7 @@ export interface CellFormat {
textColor?: string;
wrapping?: CellWrapping; // default is overflow
alignment?: CellAlignment; // default is left
textFormat?: CellTextFormat;
}

export enum BorderType {
Expand Down
3 changes: 2 additions & 1 deletion src/core/gridGL/UI/cells/Cells.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Container, Rectangle } from 'pixi.js';
import { CELL_TEXT_MARGIN_LEFT, CELL_TEXT_MARGIN_TOP } from '../../../../constants/gridConstants';
import { CellTextFormatter } from '../../../formatting/cellTextFormatter';
import { CellRectangle } from '../../../gridDB/CellRectangle';
import { CellAndFormat } from '../../../gridDB/GridSparse';
import { Cell, CellFormat } from '../../../gridDB/gridTypes';
Expand Down Expand Up @@ -127,7 +128,7 @@ export class Cells extends Container {
this.cellLabels.add({
x: x + CELL_TEXT_MARGIN_LEFT,
y: y + CELL_TEXT_MARGIN_TOP,
text: entry.cell.value,
text: CellTextFormatter(entry.cell, entry.format),
isQuadrant,
expectedWidth: width - CELL_TEXT_MARGIN_LEFT * 2,
location: isQuadrant ? { x: entry.cell.x, y: entry.cell.y } : undefined,
Expand Down
2 changes: 1 addition & 1 deletion src/core/gridGL/interaction/keyboard/keyboardCell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export function keyboardCell(options: {
}

// if key is a letter number or space start taking input
if (isAlphaNumeric(event.key) || event.key === ' ') {
if (isAlphaNumeric(event.key) || event.key === ' ' || event.key === '.') {
setInteractionState({
...interactionState,
...{
Expand Down
2 changes: 1 addition & 1 deletion src/core/transaction/runners/setCellFormatRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { PixiApp } from '../../gridGL/pixiApp/PixiApp';

const CopyCellFormat = (format: CellFormat | undefined): CellFormat | undefined => {
if (format === undefined) return undefined;
return { ...format };
return { ...format, textFormat: format.textFormat !== undefined ? { ...format.textFormat } : undefined }; // deep copy the textFormat
};

export const SetCellFormatRunner = (sheet: Sheet, statement: Statement, app?: PixiApp): Statement => {
Expand Down
5 changes: 5 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ body {
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}

/* Override third-party default to inherit from document defaults */
.szh-menu {
color: inherit !important;
}
18 changes: 18 additions & 0 deletions src/ui/icons/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ export const BorderThick = (props: SvgIconProps) => (
</SvgIcon>
);

export const DecimalIncrease = (props: SvgIconProps) => (
<SvgIcon {...props}>
<path d="m18 22-1.4-1.4 1.575-1.6H12v-2h6.175L16.6 15.4 18 14l4 4ZM2 13v-3h3v3Zm7.5 0q-1.45 0-2.475-1.025Q6 10.95 6 9.5v-4q0-1.45 1.025-2.475Q8.05 2 9.5 2q1.45 0 2.475 1.025Q13 4.05 13 5.5v4q0 1.45-1.025 2.475Q10.95 13 9.5 13Zm9 0q-1.45 0-2.475-1.025Q15 10.95 15 9.5v-4q0-1.45 1.025-2.475Q17.05 2 18.5 2q1.45 0 2.475 1.025Q22 4.05 22 5.5v4q0 1.45-1.025 2.475Q19.95 13 18.5 13Zm-9-2q.625 0 1.062-.438Q11 10.125 11 9.5v-4q0-.625-.438-1.062Q10.125 4 9.5 4t-1.062.438Q8 4.875 8 5.5v4q0 .625.438 1.062Q8.875 11 9.5 11Zm9 0q.625 0 1.062-.438Q20 10.125 20 9.5v-4q0-.625-.438-1.062Q19.125 4 18.5 4t-1.062.438Q17 4.875 17 5.5v4q0 .625.438 1.062.437.438 1.062.438Z" />
</SvgIcon>
);

export const DecimalDecrease = (props: SvgIconProps) => (
<SvgIcon {...props}>
<path d="m16 22-4-4 4-4 1.4 1.4-1.575 1.6H22v2h-6.175l1.575 1.6ZM2 13v-3h3v3Zm7.5 0q-1.45 0-2.475-1.025Q6 10.95 6 9.5v-4q0-1.45 1.025-2.475Q8.05 2 9.5 2q1.45 0 2.475 1.025Q13 4.05 13 5.5v4q0 1.45-1.025 2.475Q10.95 13 9.5 13Zm0-2q.625 0 1.062-.438Q11 10.125 11 9.5v-4q0-.625-.438-1.062Q10.125 4 9.5 4t-1.062.438Q8 4.875 8 5.5v4q0 .625.438 1.062Q8.875 11 9.5 11Z" />
</SvgIcon>
);

export const Icon123 = (props: SvgIconProps) => (
<SvgIcon {...props}>
<path d="M4.64518 15V10.3002H3V9H6.07057V15H4.64518ZM8.34684 15V12.3252C8.34684 12.0752 8.4381 11.8626 8.6206 11.6874C8.80355 11.5126 9.04133 11.4252 9.33395 11.4252H11.8563V10.3002H8.34684V9H12.2953C12.5695 9 12.8024 9.0834 12.9942 9.2502C13.1863 9.4166 13.2824 9.6332 13.2824 9.9V11.6748C13.2824 11.9248 13.1863 12.1374 12.9942 12.3126C12.8024 12.4874 12.5695 12.5748 12.2953 12.5748H9.77223V13.6998H13.2824V15H8.34684ZM15.0645 15V13.6998H18.574V12.5748H16.1615V11.4252H18.574V10.3002H15.0645V9H19.0129C19.3055 9 19.5431 9.0834 19.7256 9.2502C19.9085 9.4166 20 9.6332 20 9.9V14.1C20 14.3668 19.9085 14.5834 19.7256 14.7498C19.5431 14.9166 19.3055 15 19.0129 15H15.0645Z" />
</SvgIcon>
);

export const Python = (props: SvgIconProps) => (
<SvgIcon {...props}>
<path d="M11.8069 2.00016C10.9884 2.00397 10.2068 2.07377 9.51898 2.19548C7.49287 2.55342 7.12501 3.30264 7.12501 4.68432V6.50909H11.913V7.11735H7.12501H5.32813C3.93662 7.11735 2.71817 7.95373 2.33706 9.54481C1.89745 11.3686 1.87795 12.5066 2.33706 14.4109C2.6774 15.8284 3.49019 16.8383 4.8817 16.8383H6.52791V14.6508C6.52791 13.0705 7.89525 11.6765 9.51898 11.6765H14.3013C15.6326 11.6765 16.6953 10.5804 16.6953 9.24347V4.68432C16.6953 3.38676 15.6007 2.41203 14.3013 2.19548C13.4788 2.05856 12.6254 1.99636 11.8069 2.00016ZM9.21764 3.4678C9.7122 3.4678 10.1161 3.87827 10.1161 4.38298C10.1161 4.88589 9.7122 5.29257 9.21764 5.29257C8.7213 5.29257 8.3192 4.88589 8.3192 4.38298C8.3192 3.87827 8.7213 3.4678 9.21764 3.4678Z" />
Expand Down
81 changes: 51 additions & 30 deletions src/ui/menus/CommandPalette/ListItems/Format.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,85 @@
import { useFormatCells } from '../../TopBar/SubMenus/useFormatCells';
import { useGetSelection } from '../../TopBar/SubMenus/useGetSelection';
import { CommandPaletteListItem } from '../CommandPaletteListItem';
import { KeyboardSymbols } from '../../../../helpers/keyboardSymbols';
import { FormatBold, FormatClear, FormatItalic } from '@mui/icons-material';
import { AbcOutlined, AttachMoney, FormatClear, Functions, Percent } from '@mui/icons-material';
import { useBorders } from '../../TopBar/SubMenus/useBorders';
import { DecimalDecrease, DecimalIncrease, Icon123 } from '../../../icons';

const ListItems = [
{
label: 'Format: Bold',
label: 'Format: Clear all',
Component: (props: any) => {
const selection = useGetSelection(props.sheetController.sheet);
const format = useFormatCells(props.sheetController, props.app);
const { clearFormatting } = useFormatCells(props.sheetController, props.app);
const { clearBorders } = useBorders(props.sheetController.sheet, props.app);
return (
<CommandPaletteListItem
{...props}
icon={<FormatBold />}
icon={<FormatClear />}
action={() => {
format.changeBold(!selection.format?.bold);
clearFormatting();
clearBorders();
}}
shortcut="B"
shortcut="\"
shortcutModifiers={KeyboardSymbols.Command}
/>
);
},
},
{
label: 'Format: Italic',
label: 'Format: Style as plain text',
Component: (props: any) => {
const selection = useGetSelection(props.sheetController.sheet);
const format = useFormatCells(props.sheetController, props.app);
const { textFormatClear } = useFormatCells(props.sheetController, props.app);
return (
<CommandPaletteListItem
{...props}
icon={<FormatItalic />}
icon={<AbcOutlined />}
action={() => {
format.changeItalic(!selection.format?.italic);
textFormatClear();
}}
shortcut="I"
shortcutModifiers={KeyboardSymbols.Command}
/>
);
},
},
{
label: 'Format: Clear all',
label: 'Format: Style as number',
Component: (props: any) => {
const { clearFormatting } = useFormatCells(props.sheetController, props.app);
const { clearBorders } = useBorders(props.sheetController.sheet, props.app);
return (
<CommandPaletteListItem
{...props}
icon={<FormatClear />}
action={() => {
clearFormatting();
clearBorders();
}}
shortcut="\"
shortcutModifiers={KeyboardSymbols.Command}
/>
);
const { textFormatSetNumber } = useFormatCells(props.sheetController, props.app);
return <CommandPaletteListItem {...props} icon={<Icon123 />} action={textFormatSetNumber} />;
},
},
{
label: 'Format: Style as currency',
Component: (props: any) => {
const { textFormatSetCurrency } = useFormatCells(props.sheetController, props.app);
return <CommandPaletteListItem {...props} icon={<AttachMoney />} action={textFormatSetCurrency} />;
},
},
{
label: 'Format: Style as percentage',
Component: (props: any) => {
const { textFormatSetPercentage } = useFormatCells(props.sheetController, props.app);
return <CommandPaletteListItem {...props} icon={<Percent />} action={textFormatSetPercentage} />;
},
},
{
label: 'Format: Style as scientific',
Component: (props: any) => {
const { textFormatSetExponential } = useFormatCells(props.sheetController, props.app);
return <CommandPaletteListItem {...props} icon={<Functions />} action={textFormatSetExponential} />;
},
},
{
label: 'Format: Increase decimal place',
Component: (props: any) => {
const { textFormatIncreaseDecimalPlaces } = useFormatCells(props.sheetController, props.app);
return <CommandPaletteListItem {...props} icon={<DecimalIncrease />} action={textFormatIncreaseDecimalPlaces} />;
},
},
{
label: 'Format: Decrease decimal place',
Component: (props: any) => {
const { textFormatDecreaseDecimalPlaces } = useFormatCells(props.sheetController, props.app);
return <CommandPaletteListItem {...props} icon={<DecimalDecrease />} action={textFormatDecreaseDecimalPlaces} />;
},
},
];
Expand Down
Loading

0 comments on commit 2932910

Please sign in to comment.