gristlabs_grist-core/app/common/ValueParser.ts
Alex Hall 8f531ef622 (core) Reference and ReferenceList formatters
Summary:
Previously, ref/reflist columns were formatted entirely based on their visible column, since they received values from the visible or display columns rather than the actual row IDs. This creates `ReferenceFormatter` and `ReferenceListFormatter` which still delegate most of the formatting work to a visible column formatter but fix a few issues:

- ReferenceList columns now actually use the options (e.g. date format) of the visible column to format their elements. Previously they were formatted generically because the visible column formatter wasn't expecting a list.
- Invalid references aren't formatted with an `#Invalid Ref` prefix.
- When the ref column displays the Row ID, it doesn't have a visible or display column. Previously this led to the references being formatted as just numbers in most cases, with special code in the widget to display them like `Table1[2]`. Now they are consistently formatted in that style throughout.

Test Plan: Updated existing tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D3212
2022-01-13 18:09:33 +02:00

354 lines
11 KiB
TypeScript

import {csvDecodeRow} from 'app/common/csvFormat';
import {BulkColValues, CellValue, ColValues, UserAction} from 'app/common/DocActions';
import {DocData} from 'app/common/DocData';
import {DocumentSettings} from 'app/common/DocumentSettings';
import * as gristTypes from 'app/common/gristTypes';
import {getReferencedTableId, isFullReferencingType} from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil';
import {safeJsonParse} from 'app/common/gutil';
import {getCurrency, NumberFormatOptions} from 'app/common/NumberFormat';
import NumberParse from 'app/common/NumberParse';
import {parseDateStrict, parseDateTime} from 'app/common/parseDate';
import {MetaRowRecord, TableData} from 'app/common/TableData';
import {DateFormatOptions, DateTimeFormatOptions, formatDecoded, FormatOptions} from 'app/common/ValueFormatter';
import {encodeObject} from 'app/plugin/objtypes';
import flatMap = require('lodash/flatMap');
import mapValues = require('lodash/mapValues');
export class ValueParser {
constructor(public type: string, public widgetOpts: FormatOptions, public docSettings: DocumentSettings) {
}
public cleanParse(value: string): any {
if (!value) {
return value;
}
return this.parse(value) ?? value;
}
public parse(value: string): any {
return value;
}
}
/**
* Same as basic Value parser, but will return null if a value is an empty string.
*/
class NullIfEmptyParser extends ValueParser {
public cleanParse(value: string): any {
if (value === "") {
return null;
}
return super.cleanParse(value);
}
}
export class NumericParser extends ValueParser {
private _parse: NumberParse;
constructor(type: string, options: NumberFormatOptions, docSettings: DocumentSettings) {
super(type, options, docSettings);
this._parse = new NumberParse(docSettings.locale, getCurrency(options, docSettings));
}
public parse(value: string): number | null {
return this._parse.parse(value);
}
}
class DateParser extends ValueParser {
public parse(value: string): any {
return parseDateStrict(value, (this.widgetOpts as DateFormatOptions).dateFormat!);
}
}
class DateTimeParser extends ValueParser {
constructor(type: string, widgetOpts: DateTimeFormatOptions, docSettings: DocumentSettings) {
super(type, widgetOpts, docSettings);
const timezone = gutil.removePrefix(type, "DateTime:") || '';
this.widgetOpts = {...widgetOpts, timezone};
}
public parse(value: string): any {
return parseDateTime(value, this.widgetOpts);
}
}
class ChoiceListParser extends ValueParser {
public cleanParse(value: string): string[] | null {
value = value.trim();
const result = (
this._parseJson(value) ||
this._parseCsv(value)
).map(v => v.trim())
.filter(v => v);
if (!result.length) {
return null;
}
return ["L", ...result];
}
private _parseJson(value: string): string[] | undefined {
// Don't parse JSON non-arrays
if (value[0] === "[") {
const arr: unknown[] | null = safeJsonParse(value, null);
return arr
// Remove nulls and empty strings
?.filter(v => v || v === 0)
// Convert values to strings, formatting nested JSON objects/arrays as JSON
.map(v => formatDecoded(v));
}
}
private _parseCsv(value: string): string[] {
// Split everything on newlines which are not allowed by the choice editor.
return flatMap(value.split(/[\n\r]+/), row => {
return csvDecodeRow(row)
.map(v => v.trim());
});
}
}
/**
* This is different from other widget options which are simple JSON
* stored on the field. These have to be specially derived
* for referencing columns. See createParser.
*/
interface ReferenceParsingOptions {
visibleColId: string;
visibleColType: string;
visibleColWidgetOpts: FormatOptions;
// If this is provided and loaded, the ValueParser will look up values directly.
// Otherwise an encoded lookup will be produced for the data engine to handle.
tableData?: TableData;
}
export class ReferenceParser extends ValueParser {
public widgetOpts: ReferenceParsingOptions;
protected _visibleColId = this.widgetOpts.visibleColId;
protected _tableData = this.widgetOpts.tableData;
protected _visibleColParser = createParserRaw(
this.widgetOpts.visibleColType,
this.widgetOpts.visibleColWidgetOpts,
this.docSettings,
);
public parse(raw: string): any {
let value = this._visibleColParser(raw);
if (!value || !raw) {
return 0; // default value for a reference column
}
if (this._visibleColId === 'id') {
const n = Number(value);
if (Number.isInteger(n)) {
value = n;
// Don't return yet because we need to check that this row ID exists
} else {
return raw;
}
}
if (!this._tableData?.isLoaded) {
const options: { column: string, raw?: string } = {column: this._visibleColId};
if (value !== raw) {
options.raw = raw;
}
return ['l', value, options];
}
return this._tableData.findMatchingRowId({[this._visibleColId]: value}) || raw;
}
}
export class ReferenceListParser extends ReferenceParser {
public parse(raw: string): any {
let values: any[] | null;
try {
values = JSON.parse(raw);
} catch {
values = null;
}
if (!Array.isArray(values)) {
// csvDecodeRow should never raise an exception
values = csvDecodeRow(raw);
}
values = values.map(v => typeof v === "string" ? this._visibleColParser(v) : encodeObject(v));
if (!values.length || !raw) {
return null; // null is the default value for a reference list column
}
if (this._visibleColId === 'id') {
const numbers = values.map(Number);
if (numbers.every(Number.isInteger)) {
values = numbers;
// Don't return yet because we need to check that these row IDs exist
} else {
return raw;
}
}
if (!this._tableData?.isLoaded) {
const options: { column: string, raw?: string } = {column: this._visibleColId};
if (!(values.length === 1 && values[0] === raw)) {
options.raw = raw;
}
return ['l', values, options];
}
const rowIds: number[] = [];
for (const value of values) {
const rowId = this._tableData.findMatchingRowId({[this._visibleColId]: value});
if (rowId) {
rowIds.push(rowId);
} else {
// There's no matching value in the visible column, i.e. this is not a valid reference.
// We need to return a string which will become AltText.
return raw;
}
}
return ['L', ...rowIds];
}
}
export const valueParserClasses: { [type: string]: typeof ValueParser } = {
Any: NullIfEmptyParser,
Numeric: NumericParser,
Int: NumericParser,
Date: DateParser,
DateTime: DateTimeParser,
ChoiceList: ChoiceListParser,
Ref: ReferenceParser,
RefList: ReferenceListParser,
};
const identity = (value: string) => value;
/**
* Returns a function which can parse strings into values appropriate for
* a specific widget field or table column.
* widgetOpts is usually the field/column's widgetOptions JSON
* but referencing columns need more than that, see ReferenceParsingOptions above.
*/
export function createParserRaw(
type: string, widgetOpts: FormatOptions, docSettings: DocumentSettings
): (value: string) => any {
const cls = valueParserClasses[gristTypes.extractTypeFromColType(type)];
if (cls) {
const parser = new cls(type, widgetOpts, docSettings);
return parser.cleanParse.bind(parser);
}
return identity;
}
/**
* Returns a function which can parse strings into values appropriate for
* a specific widget field or table column.
*
* Pass fieldRef (a row ID of _grist_Views_section_field) to use the settings of that view field
* instead of the table column.
*/
export function createParser(
docData: DocData,
colRef: number,
fieldRef?: number,
): (value: string) => any {
const columnsTable = docData.getMetaTable('_grist_Tables_column');
const fieldsTable = docData.getMetaTable('_grist_Views_section_field');
const docInfoTable = docData.getMetaTable('_grist_DocInfo');
const col = columnsTable.getRecord(colRef)!;
let fieldOrCol: MetaRowRecord<'_grist_Tables_column' | '_grist_Views_section_field'> = col;
if (fieldRef) {
fieldOrCol = fieldsTable.getRecord(fieldRef) || col;
}
const widgetOpts = safeJsonParse(fieldOrCol.widgetOptions, {});
const type = col.type;
if (isFullReferencingType(type)) {
const vcol = columnsTable.getRecord(fieldOrCol.visibleCol);
widgetOpts.visibleColId = vcol?.colId || 'id';
widgetOpts.visibleColType = vcol?.type;
widgetOpts.visibleColWidgetOpts = safeJsonParse(vcol?.widgetOptions || '', {});
widgetOpts.tableData = docData.getTable(getReferencedTableId(type)!);
}
const docInfo = docInfoTable.getRecord(1);
const docSettings = safeJsonParse(docInfo!.documentSettings, {}) as DocumentSettings;
return createParserRaw(type, widgetOpts, docSettings);
}
/**
* Returns a copy of `colValues` with string values parsed according to the type and options of each column.
* `bulk` should be `true` if `colValues` is of type `BulkColValues`.
*/
function parseColValues<T extends ColValues | BulkColValues>(
tableId: string, colValues: T, docData: DocData, bulk: boolean
): T {
const columnsTable = docData.getMetaTable('_grist_Tables_column');
const tablesTable = docData.getMetaTable('_grist_Tables');
const tableRef = tablesTable.findRow('tableId', tableId);
if (!tableRef) {
return colValues;
}
return mapValues(colValues, (values, colId) => {
const colRef = columnsTable.findMatchingRowId({colId, parentId: tableRef});
if (!colRef) {
// Column not found - let something else deal with that
return values;
}
const parser = createParser(docData, colRef);
// Optimisation: If there's no special parser for this column type, do nothing
if (parser === identity) {
return values;
}
function parseIfString(val: any) {
return typeof val === "string" ? parser(val) : val;
}
if (bulk) {
if (!Array.isArray(values)) { // in case of bad input
return values;
}
// `colValues` is of type `BulkColValues`
return (values as CellValue[]).map(parseIfString);
} else {
// `colValues` is of type `ColValues`, `values` is just one value
return parseIfString(values);
}
});
}
export function parseUserAction(ua: UserAction, docData: DocData): UserAction {
const actionType = ua[0] as string;
let parseBulk: boolean;
if (['AddRecord', 'UpdateRecord'].includes(actionType)) {
parseBulk = false;
} else if (['BulkAddRecord', 'BulkUpdateRecord', 'ReplaceTableData'].includes(actionType)) {
parseBulk = true;
} else {
return ua;
}
ua = ua.slice();
const tableId = ua[1] as string;
const lastIndex = ua.length - 1;
const colValues = ua[lastIndex] as ColValues | BulkColValues;
ua[lastIndex] = parseColValues(tableId, colValues, docData, parseBulk);
return ua;
}