(core) Parsing multiple values in reflists, parsing refs without table data in client

Summary:
Added a new object type code `l` (for lookup) which can be used in user actions as a temporary cell value in ref[list] columns and is immediately converted to a row ID in the data engine. The value contains the original raw string (to be used as alt text), the column ID to lookup (typically the visible column) and one or more values to lookup.

For reflists, valueParser now tries parsing the string first as JSON, then as a CSV row, and applies the visible column parsed to each item.

Both ref and reflists columns no longer format the parsed value when there's no matching reference, the original unparsed string is used as alttext instead.

Test Plan: Added another table "Multi-References" to CopyPaste test. Made that table and the References table test with and without table data loaded in the browser.

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D3118
This commit is contained in:
Alex Hall
2021-11-09 14:11:37 +02:00
parent b6dd066b7f
commit ecb30eebb8
7 changed files with 238 additions and 54 deletions

View File

@@ -1,9 +1,9 @@
import { DocData } from 'app/client/models/DocData';
import { ColumnRec } from 'app/client/models/entities/ColumnRec';
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
import { SearchFunc, TableData } from 'app/client/models/TableData';
import { getReferencedTableId } from 'app/common/gristTypes';
import { BaseFormatter } from 'app/common/ValueFormatter';
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {SearchFunc, TableData} from 'app/client/models/TableData';
import {getReferencedTableId, isRefListType} from 'app/common/gristTypes';
import {BaseFormatter} from 'app/common/ValueFormatter';
import isEqual = require('lodash/isEqual');
/**
@@ -15,6 +15,7 @@ export class ReferenceUtils {
public readonly formatter: BaseFormatter;
public readonly visibleColModel: ColumnRec;
public readonly visibleColId: string;
public readonly isRefList: boolean;
constructor(public readonly field: ViewFieldRec, docData: DocData) {
// Note that this constructor is called inside ViewFieldRec.valueParser, a ko.pureComputed,
@@ -36,50 +37,83 @@ export class ReferenceUtils {
this.formatter = field.createVisibleColFormatter();
this.visibleColModel = field.visibleColModel();
this.visibleColId = this.visibleColModel.colId() || 'id';
this.isRefList = isRefListType(colType);
}
public parseValue(value: any): number | string {
if (!value) {
return 0; // This is the default value for a reference column.
public parseReference(
raw: string, value: unknown
): number | string | ['l', unknown, {raw?: string, column: string}] {
if (!value || !raw) {
return 0; // default value for a reference column
}
if (this.visibleColId === 'id') {
const n = Number(value);
if (
n > 0 &&
Number.isInteger(n) &&
!(
this.tableData.isLoaded &&
!this.tableData.hasRowId(n)
)
) {
return n;
if (Number.isInteger(n)) {
value = n;
} else {
return raw;
}
return String(value);
}
let searchFunc: SearchFunc;
if (typeof value === 'string') {
searchFunc = (v: any) => {
const formatted = this.formatter.formatAny(v);
return nocaseEqual(formatted, value);
};
} else {
searchFunc = (v: any) => isEqual(v, value);
if (!this.tableData.isLoaded) {
const options: {column: string, raw?: string} = {column: this.visibleColId};
if (value !== raw) {
options.raw = raw;
}
return ['l', value, options];
}
const searchFunc: SearchFunc = (v: any) => isEqual(v, value);
const matches = this.tableData.columnSearch(this.visibleColId, searchFunc, 1);
if (matches.length > 0) {
return matches[0];
} 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.
// Can't return `value` directly because it may be a number (if visibleCol is a numeric or date column)
// which would be interpreted as a row ID, i.e. a valid reference.
// So instead we format the parsed value in the style of visibleCol.
return this.formatter.formatAny(value);
return raw;
}
}
public parseReferenceList(
raw: string, values: unknown[]
): ['L', ...number[]] | null | string | ['l', unknown[], {raw?: string, column: string}] {
if (!values.length || !raw) {
return null; // default value for a reference list column
}
if (this.visibleColId === 'id') {
const numbers = values.map(Number);
if (numbers.every(Number.isInteger)) {
values = numbers;
} 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 searchFunc: SearchFunc = (v: any) => isEqual(v, value);
const matches = this.tableData.columnSearch(this.visibleColId, searchFunc, 1);
if (matches.length > 0) {
rowIds.push(matches[0]);
} 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];
}
public idToText(value: unknown) {
if (typeof value === 'number') {
return this.formatter.formatAny(this.tableData.getValue(value, this.visibleColId));

View File

@@ -1,12 +1,13 @@
import { ReferenceUtils } from 'app/client/lib/ReferenceUtils';
import { ColumnRec, DocModel, IRowModel, refRecord, ViewSectionRec } from 'app/client/models/DocModel';
import {ReferenceUtils} from 'app/client/lib/ReferenceUtils';
import {ColumnRec, DocModel, IRowModel, refRecord, ViewSectionRec} from 'app/client/models/DocModel';
import * as modelUtil from 'app/client/models/modelUtil';
import * as UserType from 'app/client/widgets/UserType';
import { DocumentSettings } from 'app/common/DocumentSettings';
import { isFullReferencingType, isRefListType } from 'app/common/gristTypes';
import { BaseFormatter, createFormatter } from 'app/common/ValueFormatter';
import { createParser } from 'app/common/ValueParser';
import { Computed, fromKo } from 'grainjs';
import {csvDecodeRow} from 'app/common/csvFormat';
import {DocumentSettings} from 'app/common/DocumentSettings';
import {isFullReferencingType} from 'app/common/gristTypes';
import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter';
import {createParser} from 'app/common/ValueParser';
import {Computed, fromKo} from 'grainjs';
import * as ko from 'knockout';
// Represents a page entry in the tree of pages.
@@ -192,24 +193,24 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
const vcol = this.visibleColModel();
const vcolParser = createParser(vcol.type(), vcol.widgetOptionsJson(), docSettings);
const refUtils = new ReferenceUtils(this, docModel.docData); // uses several more observables immediately
return (s: string) => {
const result = refUtils.parseValue(vcolParser(s));
// If `result` is a number that means it successfully parsed a reference (row ID).
// For a reflist we need to wrap that row ID in a list.
// Otherwise `result` is a string meaning it couldn't be parsed
// and it will be saved as AltText (i.e. invalid for the column type).
// We don't try to parse multiple references from a single string.
if (isRefListType(type) && typeof result === "number") {
if (result > 0) {
return ['L', result];
} else {
// parseValue returns 0 sometimes because that's the default for reference columns.
// The default for a reflist column is null.
return null;
if (!refUtils.isRefList) {
return (s: string) => refUtils.parseReference(s, vcolParser(s));
} else {
return (s: string) => {
let values: any[] | null;
try {
values = JSON.parse(s);
} catch {
values = null;
}
}
return result;
};
if (!Array.isArray(values)) {
// csvDecodeRow should never raise an exception
values = csvDecodeRow(s);
}
values = values.map(v => typeof v === "string" ? vcolParser(v) : v);
return refUtils.parseReferenceList(s, values);
};
}
}
});